feat(coach): wire contract for steps, result tables, method, follow-ups and clarification

Adds the TypeScript types for the four additive stream frames and their
provenance fields, a Zod mirror for the published document held equal to
the types by a type test, and a guard that no newer frame reuses a key the
shipped native client decodes. The chat request accepts followUp and
clarification, inert for now.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 10:33:13 +02:00
1 parent 620c538f9d
commit cde7af3cf8
4 files changed
+922

No files matched your search

@@ -0,0 +1,106 @@
/**
* No Coach stream frame added after the native client shipped may carry a
* top-level key that client already decodes.
*
* The native client decodes every frame into one flat struct —
* `{ type, token, conversationId, messageId, code, message, suggestion,
* metricSource, usage }` — and dispatches on `type`, ignoring a type it does
* not know. That makes an unknown frame harmless only while its keys are
* unknown too: a new frame that reused `message` or `usage` with a different
* shape would make the whole frame fail to decode, and a new frame that
* reused one with the same shape would leak a value into whatever the client
* does with that key. So a new frame names its payload with a key of its own
* (`step`, `result`, `followUps`, `clarification`).
*
* Reads the frame list from the Zod mirror (`stream-events.ts`), which a
* type test holds equal to `CoachStreamEvent`, so a frame added to the
* TypeScript union is seen here too.
*
* Checked by breaking it: adding `message: z.string()` to the `step` frame in
* `stream-events.ts` turns the second test red and names the frame and key.
*/
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { coachStreamEventSchema } from "@/lib/ai/coach/stream-events";
/** The keys the shipped native client decodes, `type` aside. */
const NATIVE_CLIENT_KEYS = [
"token",
"conversationId",
"messageId",
"code",
"message",
"suggestion",
"metricSource",
"usage",
] as const;
/** The frames the shipped client was built against; they own those keys. */
const FRAMES_THE_CLIENT_KNOWS = new Set([
"token",
"provenance",
"suggestion",
"suggestedAction",
"reasoning",
"done",
"error",
]);
type FrameSchema = z.ZodObject<{ type: z.ZodLiteral<string> } & z.ZodRawShape>;
function frames(): Array<{ type: string; keys: string[] }> {
return (coachStreamEventSchema.options as readonly FrameSchema[]).map(
(option) => ({
type: String(option.shape.type.value),
keys: Object.keys(option.shape),
}),
);
}
/** Every `frame.key` pair where a newer frame reuses a native-client key. */
function collisions(list: Array<{ type: string; keys: string[] }>): string[] {
const reserved: ReadonlySet<string> = new Set(NATIVE_CLIENT_KEYS);
return list
.filter((frame) => !FRAMES_THE_CLIENT_KNOWS.has(frame.type))
.flatMap((frame) =>
frame.keys
.filter((key) => reserved.has(key))
.map((key) => `${frame.type}.${key}`),
);
}
describe("Coach stream frames stay decodable by the shipped native client", () => {
it("reads every frame, including the ones added after the client shipped", () => {
const types = frames().map((frame) => frame.type);
// An empty or partial list would agree with anything.
expect(types).toEqual(
expect.arrayContaining([
...FRAMES_THE_CLIENT_KNOWS,
"step",
"result",
"followUps",
"clarification",
]),
);
expect(
types.filter((type) => !FRAMES_THE_CLIENT_KNOWS.has(type)).length,
).toBeGreaterThan(0);
});
it("no newer frame carries a key the native client decodes", () => {
expect(
collisions(frames()),
"rename the payload key: the native client decodes these keys on every frame",
).toEqual([]);
});
it("the check flags a colliding frame", () => {
expect(
collisions([
{ type: "step", keys: ["type", "step", "message"] },
{ type: "done", keys: ["type", "conversationId", "messageId"] },
]),
).toEqual(["step.message"]);
});
});
@@ -0,0 +1,71 @@
/**
* The Zod mirror of the Coach wire (`stream-events.ts`) and the TypeScript
* contract (`types.ts`) describe the same shapes.
*
* The checks are compile-time: `expectTypeOf(...).toEqualTypeOf<...>()`
* fails `pnpm typecheck` when a field is added, dropped, renamed or retyped
* on one side only. Equality is exact in both directions, so a field that
* is optional on one side and required on the other fails as well.
*
* Checked by breaking it: removing `resultRef` from `coachStepSchema`, and
* making `CoachFollowUp.reuse` optional in `types.ts`, each fail the
* typecheck on the matching line below.
*/
import { describe, expectTypeOf, it } from "vitest";
import type { z } from "zod/v4";
import type {
CoachClarification,
CoachFollowUp,
CoachMethod,
CoachProvenance,
CoachResultEntry,
CoachResultMeta,
CoachResultTable,
CoachStep,
CoachStreamEvent,
} from "@/lib/ai/coach/types";
import {
coachClarificationSchema,
coachFollowUpSchema,
coachMethodSchema,
coachProvenanceSchema,
coachResultEntrySchema,
coachResultMetaSchema,
coachResultTableSchema,
coachStepSchema,
coachStreamEventSchema,
} from "@/lib/ai/coach/stream-events";
describe("Coach wire: Zod mirror equals the TypeScript contract", () => {
it("every frame", () => {
expectTypeOf<
z.infer<typeof coachStreamEventSchema>
>().toEqualTypeOf<CoachStreamEvent>();
});
it("each component the OpenAPI document names", () => {
expectTypeOf<z.infer<typeof coachStepSchema>>().toEqualTypeOf<CoachStep>();
expectTypeOf<
z.infer<typeof coachResultMetaSchema>
>().toEqualTypeOf<CoachResultMeta>();
expectTypeOf<
z.infer<typeof coachResultTableSchema>
>().toEqualTypeOf<CoachResultTable>();
expectTypeOf<
z.infer<typeof coachResultEntrySchema>
>().toEqualTypeOf<CoachResultEntry>();
expectTypeOf<
z.infer<typeof coachMethodSchema>
>().toEqualTypeOf<CoachMethod>();
expectTypeOf<
z.infer<typeof coachFollowUpSchema>
>().toEqualTypeOf<CoachFollowUp>();
expectTypeOf<
z.infer<typeof coachClarificationSchema>
>().toEqualTypeOf<CoachClarification>();
expectTypeOf<
z.infer<typeof coachProvenanceSchema>
>().toEqualTypeOf<CoachProvenance>();
});
});
+484
View File
@@ -0,0 +1,484 @@
/**
* The Coach chat wire as Zod schemas.
*
* `types.ts` holds the TypeScript contract the server and the web client
* compile against; this file is its mirror for the published OpenAPI
* document (`docs/api/openapi.yaml`), which the native client reads. The two
* are held equal by `__tests__/stream-events-type-parity.test.ts`: a field
* added on one side and not the other fails the typecheck.
*
* Nothing here parses a live frame. The server builds frames from typed
* values and the client dispatches on `type`; these schemas describe the
* shapes, they do not police them.
*/
import { z } from "zod/v4";
import { AI_UNAVAILABLE_REASONS } from "@/lib/ai/capabilities/types";
import {
SUGGESTED_ACTION_TYPES,
type CheckupIntervalId,
} from "@/lib/ai/coach/suggest-action";
import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions";
import {
coachKeyValueSchema,
coachScopeSourceSchema,
coachScopeWindowSchema,
} from "@/lib/ai/coach/types";
const coachWindowEnum = coachScopeWindowSchema;
const coachToolNameEnum = z.enum(COACH_TOOL_NAMES);
export const coachProvenanceMetricSchema = z.enum([
...coachScopeSourceSchema.options,
"general",
]);
export const coachStepDomainSchema = z
.enum([
...coachScopeSourceSchema.options,
"labs",
"illness",
"cycle",
"correlations",
"snapshot",
])
.meta({
id: "CoachStepDomain",
description:
"The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a whole (labs, illness, cycle, correlations, the full snapshot).",
});
const coachResultGranularitySchema = z.enum(["day", "week", "month"]);
const coachResultPeriodSchema = z.enum(["current", "previous", "yearAgo"]);
// ── Steps ─────────────────────────────────────────────────────────────────
export const coachStepSchema = z
.object({
id: z.string().describe("`s1`..`s12`, unique within a turn."),
tool: z.enum([...COACH_TOOL_NAMES, "show_result", "snapshot"]),
labelKey: z
.string()
.describe("Closed catalog key (`coach.step.*`) the client localises."),
label: z
.string()
.describe(
"The label rendered on the server in the request locale. Render it when the client has no string for `labelKey`.",
),
domain: coachStepDomainSchema.optional(),
window: coachWindowEnum.optional(),
period: coachResultPeriodSchema.optional(),
granularity: coachResultGranularitySchema.optional(),
status: z.enum(["running", "done", "empty", "failed"]),
count: z
.number()
.int()
.optional()
.describe("Readings or rows the server counted. Never a health value."),
reason: z
.enum([
"no_data",
"outside_window",
"module_disabled",
"retrieval_failed",
"invalid_arguments",
])
.optional(),
resultRef: z
.string()
.optional()
.describe("The table this step produced (`r1`..), when it produced one."),
})
.meta({
id: "CoachStep",
description:
"One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value.",
});
// ── Results ───────────────────────────────────────────────────────────────
const coachResultColumnSchema = z.object({
key: z.string(),
kind: z.enum(["period", "category", "number", "count"]),
labelKey: z.string(),
label: z.string(),
unit: z
.string()
.optional()
.describe("Display unit token such as `mmHg`, never free text."),
decimals: z.number().int().optional(),
});
const coachChartSpecSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("line"),
x: z.string(),
series: z.array(z.string()),
}),
z.object({
kind: z.literal("bar"),
x: z.string(),
series: z.array(z.string()),
orientation: z.enum(["vertical", "horizontal"]),
}),
z.object({
kind: z.literal("histogram"),
column: z.string(),
unit: z.string().optional(),
bins: z.array(
z.object({ from: z.number(), to: z.number(), count: z.number().int() }),
),
}),
]);
const coachResultSourceSchema = z.object({
tool: coachToolNameEnum,
domain: coachStepDomainSchema,
window: coachWindowEnum,
period: coachResultPeriodSchema,
granularity: coachResultGranularitySchema.optional(),
});
const coachResultMetaShape = {
ref: z.string().describe("`r1`..`r6`, unique within a message."),
source: coachResultSourceSchema,
shape: z.enum(["timeSeries", "categoryCounts", "distribution", "single"]),
titleKey: z.string(),
title: z.string(),
rowCount: z.number().int().describe("The full row count, before any trim."),
chartKind: z.enum(["line", "bar", "histogram"]).nullable(),
displayed: z
.boolean()
.describe(
"True when the answer referenced the table (shown expanded); false when it sits under the data-used disclosure.",
),
reusedFrom: z
.object({ messageId: z.string(), ref: z.string() })
.optional()
.describe("Set when the table was copied from an earlier message."),
};
export const coachResultMetaSchema = z.object(coachResultMetaShape).meta({
id: "CoachResultMeta",
description:
"A table's metadata, persisted in the plaintext provenance (`metricSource.results`). The values are not here: fetch them from `GET /api/insights/chat/{id}/messages/{messageId}/results`.",
});
export const coachResultTableSchema = z
.object({
...coachResultMetaShape,
columns: z.array(coachResultColumnSchema),
rows: z
.array(z.array(z.union([z.string(), z.number(), z.null()])))
.describe(
"Row-major cells, at most 400 rows. `null` is a period with no reading.",
),
truncated: z.boolean().describe("True when `rowCount` exceeds the rows."),
chart: coachChartSpecSchema
.nullable()
.describe("The chart the server chose for the table, or null."),
})
.meta({
id: "CoachResultTable",
description:
"A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns the conversation.",
});
const coachResultWithheldSchema = z.object({
ref: z.string(),
withheld: z
.enum(["module_disabled", "unavailable"])
.describe(
"`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables could not be read.",
),
});
export const coachResultEntrySchema = z
.union([coachResultTableSchema, coachResultWithheldSchema])
.meta({
id: "CoachResultEntry",
description:
"One stored table, or the reason it is not served. Absence is explicit: a withheld table names why.",
});
// ── Method ────────────────────────────────────────────────────────────────
export const coachMethodSchema = z
.object({
entries: z.array(
z.object({
domain: coachStepDomainSchema,
window: coachWindowEnum.optional(),
period: coachResultPeriodSchema.optional(),
granularity: coachResultGranularitySchema.optional(),
count: z.number().int().optional(),
aggregation: z
.enum(["mean", "median", "latest", "sum", "count", "rate"])
.optional(),
absent: z
.enum(["no_data", "outside_window", "module_disabled"])
.optional(),
}),
),
text: z.string().describe("Rendered on the server in the request locale."),
})
.meta({
id: "CoachMethod",
description:
"How an answer was reached: which sources, windows, counts and aggregation. Never a health value.",
});
// ── Follow-ups ────────────────────────────────────────────────────────────
export const coachFollowUpSchema = z
.object({
id: z.string().describe("`f1`..`f3`."),
kind: z.enum([
"widen_window",
"previous_period",
"year_ago",
"as_chart",
"as_table",
"related_metric",
"continue",
]),
labelKey: z.string(),
label: z
.string()
.describe("Rendered on the server from a catalog; never model text."),
anchor: z
.object({
ref: z.string(),
domain: coachStepDomainSchema,
window: coachWindowEnum.optional(),
granularity: coachResultGranularitySchema.optional(),
period: coachResultPeriodSchema.optional(),
})
.optional(),
reuse: z
.boolean()
.describe(
"True when the chip is answered from a stored table without a model call.",
),
origin: z.enum(["server", "model"]),
})
.meta({
id: "CoachFollowUp",
description:
"A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the chip's label as `message`.",
});
// ── Clarification ─────────────────────────────────────────────────────────
export const coachClarificationSchema = z
.object({
kind: z.enum(["metric", "window", "context"]),
choices: z
.array(
z.object({
id: z.string(),
labelKey: z.string(),
label: z.string(),
value: z.object({
metric: coachScopeSourceSchema.optional(),
window: coachWindowEnum.optional(),
}),
}),
)
.describe("At most 4. Metric choices are always ones the record holds."),
freeText: z.boolean().describe("Whether a typed answer is welcome."),
})
.meta({
id: "CoachClarification",
description:
"The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`.",
});
// ── The cards and the usage envelope ──────────────────────────────────────
const coachSuggestionSchema = z
.object({
cadenceId: z.string(),
measurementType: z.string(),
label: z.string(),
})
.meta({
id: "CoachSuggestion",
description:
"A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`.",
});
const checkupIntervalSchema = z.string() as z.ZodType<CheckupIntervalId>;
const coachSuggestedActionSchema = z
.object({
actionType: z.enum(SUGGESTED_ACTION_TYPES),
summary: z.string(),
titleKey: z.string(),
params: z.discriminatedUnion("actionType", [
z.object({
actionType: z.literal("checkup.create"),
label: z.string(),
interval: checkupIntervalSchema,
}),
z.object({
actionType: z.literal("reminder.note"),
note: z.string(),
when: z.string().optional(),
metric: z.string().optional(),
}),
]),
})
.meta({
id: "CoachSuggestedAction",
description:
"A confirm-to-apply action card from a closed allowlist. Nothing is created until the person confirms it through `POST /api/coach/suggested-actions`.",
});
const coachUsageSchema = z
.object({
totalTokens: z.number().int().nullable(),
promptTokens: z.number().int().nullable().optional(),
completionTokens: z.number().int().nullable().optional(),
model: z.string().nullable().optional(),
})
.meta({
id: "CoachUsage",
description:
"Per-turn token usage. Server-authoritative: clients display it, never recompute it.",
});
// ── Provenance ────────────────────────────────────────────────────────────
export const coachProvenanceSchema = z
.object({
windows: z
.array(coachWindowEnum)
.readonly()
.describe("Analysis windows the assistant drew on this turn."),
metrics: z
.array(coachProvenanceMetricSchema)
.readonly()
.describe(
"Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.",
),
counts: z
.partialRecord(coachProvenanceMetricSchema, z.number().int())
.optional()
.describe(
"Per-metric sample-count summary; absent on an empty snapshot.",
),
keyValues: z
.array(coachKeyValueSchema)
.readonly()
.optional()
.describe(
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
),
suggestion: coachSuggestionSchema
.optional()
.describe("The cadence-suggestion card this turn carried, if any."),
suggestedAction: coachSuggestedActionSchema
.optional()
.describe("The confirm-to-apply action card this turn carried, if any."),
toolCalls: z
.array(z.object({ name: z.string(), present: z.boolean() }))
.readonly()
.optional()
.describe(
"v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.",
),
groundedFigures: z
.array(z.number())
.readonly()
.optional()
.describe(
"Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed, label-less.",
),
unverifiedFigures: z
.number()
.int()
.positive()
.optional()
.describe(
"v1.32.14 — count of numeric tokens the grounding guard withheld from this reply (each rewritten to the editorial elision mark). Drives the quiet per-message notice. Count only, never the withheld values. Absent when the turn withheld nothing.",
),
steps: z
.array(coachStepSchema)
.optional()
.describe("v1.39.4 — what the Coach read on this turn."),
method: coachMethodSchema
.optional()
.describe("v1.39.4 — how the answer was reached."),
results: z
.array(coachResultMetaSchema)
.optional()
.describe(
"v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint.",
),
followUps: z
.array(coachFollowUpSchema)
.optional()
.describe("v1.39.4 — the chips offered under this reply."),
clarification: coachClarificationSchema
.optional()
.describe("v1.39.4 — the choices, when this reply is a question."),
forcedFinal: z
.literal(true)
.optional()
.describe(
"v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.",
),
})
.meta({
id: "CoachProvenance",
description:
"Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values and cards. No raw timestamps and no table values.",
});
// ── Frames ────────────────────────────────────────────────────────────────
export const coachStreamEventSchema = z
.discriminatedUnion("type", [
z.object({ type: z.literal("token"), token: z.string() }),
z.object({
type: z.literal("provenance"),
metricSource: coachProvenanceSchema,
}),
z.object({
type: z.literal("suggestion"),
suggestion: coachSuggestionSchema,
}),
z.object({
type: z.literal("suggestedAction"),
suggestedAction: coachSuggestedActionSchema,
}),
z.object({ type: z.literal("reasoning"), text: z.string() }),
z.object({
type: z.literal("done"),
conversationId: z.string(),
messageId: z.string(),
usage: coachUsageSchema.optional(),
}),
z.object({
type: z.literal("error"),
code: z.string(),
message: z.string(),
reason: z.enum(AI_UNAVAILABLE_REASONS).optional(),
}),
z.object({ type: z.literal("step"), step: coachStepSchema }),
z.object({ type: z.literal("result"), result: coachResultTableSchema }),
z.object({
type: z.literal("followUps"),
followUps: z.array(coachFollowUpSchema),
}),
z.object({
type: z.literal("clarification"),
clarification: coachClarificationSchema,
}),
])
.meta({
id: "CoachStreamEvent",
description:
"One Server-Sent Events frame of a Coach turn, sent as `data: <json>\\n\\n` and dispatched on `type`. Order: `step*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → `clarification?` → `followUps?` → `done`, or a single `error`. Clients ignore a `type` they do not know.",
});
+261
View File
@@ -10,6 +10,7 @@ import { z } from "zod/v4";
import type { AiUnavailableReason } from "@/lib/ai/capabilities/types";
import type { CoachSuggestedAction } from "./suggest-action";
import type { CoachToolName } from "./tools/definitions";
/**
* Chat-message role. Stored as a free-form string column server-side
@@ -157,6 +158,31 @@ export const coachChatRequestSchema = z.object({
* turn, so per-turn work does not grow with conversation length.
*/
workoutId: z.string().max(64).optional(),
/**
* v1.39.4 — the person tapped a follow-up chip. `messageId` is the
* assistant message that offered it and `id` the chip on that message
* (`f1`..`f3`). The server resolves the chip from what it persisted on
* the conversation's latest assistant message; the client never sends
* what the chip asks for, only which chip it was. A chip that is no
* longer current degrades to a plain message.
*/
followUp: z
.object({
messageId: z.string().min(1).max(64),
id: z.string().min(1).max(8),
})
.optional(),
/**
* v1.39.4 — the person answered a clarifying question. `messageId` is
* the assistant message that asked it; `choiceId` the chip they tapped,
* absent when they typed their own answer instead.
*/
clarification: z
.object({
messageId: z.string().min(1).max(64),
choiceId: z.string().min(1).max(8).optional(),
})
.optional(),
});
/**
@@ -212,6 +238,21 @@ export interface CoachUsage {
* inside the thinking disclosure when a reasoning-capable provider
* emits a cheap reasoning summary. Providers without reasoning emit
* none, and the disclosure falls back to its elapsed-time label.
*
* v1.39.4 — four additive frames: `step` (live progress while the Coach
* reads the record), `result` (a table of the values it read, owner only),
* `followUps` (chips under the latest reply) and `clarification` (a
* question with choices). Frame order on a turn:
*
* step* → token* → provenance → result* → suggestion? →
* suggestedAction? → clarification? → followUps? → done
*
* No new frame carries a top-level key the older native client decodes
* (`token`, `conversationId`, `messageId`, `code`, `message`, `suggestion`,
* `metricSource`, `usage`), so an older client drops them whole.
* `src/__tests__/coach-stream-ios-compat.test.ts` holds that rule, and the
* Zod mirror in `stream-events.ts` is held equal to this union by a type
* test.
*/
export type CoachStreamEvent =
| { type: "token"; token: string }
@@ -238,8 +279,205 @@ export type CoachStreamEvent =
* ignore it.
*/
reason?: AiUnavailableReason;
}
| { type: "step"; step: CoachStep }
| { type: "result"; result: CoachResultTable }
| { type: "followUps"; followUps: CoachFollowUp[] }
| { type: "clarification"; clarification: CoachClarification };
// ── Steps (v1.39.4) ─────────────────────────────────────────────────────
// What the Coach is reading while a turn runs. Labels come from a closed
// catalog; a step never carries free text, an analyte name, or a value.
export type CoachStepStatus = "running" | "done" | "empty" | "failed";
/**
* The data domain a step, result or chip is about. The measurement-backed
* scope sources plus the domains that are read as a whole.
*/
export type CoachStepDomain =
CoachScopeSource | "labs" | "illness" | "cycle" | "correlations" | "snapshot";
/** Why a step or a method entry found nothing. */
export type CoachStepReason =
| "no_data"
| "outside_window"
| "module_disabled"
| "retrieval_failed"
| "invalid_arguments";
export interface CoachStep {
/** `s1`..`s12`, unique within a turn; frames upsert by id. */
id: string;
tool: CoachToolName | "show_result" | "snapshot";
/** Closed catalog key, e.g. `coach.step.read`. */
labelKey: string;
/** The label rendered on the server in the request locale; the fallback. */
label: string;
domain?: CoachStepDomain;
window?: CoachScopeWindow;
period?: CoachResultPeriod;
granularity?: CoachResultGranularity;
status: CoachStepStatus;
/** Readings or rows the server counted. Never a health value. */
count?: number;
reason?: CoachStepReason;
/** `r1` when the step produced a table. */
resultRef?: string;
}
// ── Results (v1.39.4) ───────────────────────────────────────────────────
// A table of the values a turn read. The values are health data: encrypted
// at rest (`coach_messages.results_encrypted`) and sent only to the owner.
// The metadata alone rides the plaintext provenance.
export type CoachResultGranularity = "day" | "week" | "month";
export type CoachResultPeriod = "current" | "previous" | "yearAgo";
export type CoachResultShape =
"timeSeries" | "categoryCounts" | "distribution" | "single";
export interface CoachResultColumn {
key: string;
kind: "period" | "category" | "number" | "count";
labelKey: string;
label: string;
/** Display unit token (e.g. `mmHg`), never free text. */
unit?: string;
decimals?: number;
}
/** `null` is a period with no reading: absence stays explicit. */
export type CoachResultCell = string | number | null;
export type CoachChartSpec =
| { kind: "line"; x: string; series: string[] }
| {
kind: "bar";
x: string;
series: string[];
orientation: "vertical" | "horizontal";
}
| {
kind: "histogram";
column: string;
unit?: string;
bins: Array<{ from: number; to: number; count: number }>;
};
export interface CoachResultSource {
tool: CoachToolName;
domain: CoachStepDomain;
window: CoachScopeWindow;
period: CoachResultPeriod;
granularity?: CoachResultGranularity;
}
/** Plaintext; rides `metricSource.results`. */
export interface CoachResultMeta {
/** `r1`..`r6`, per message. */
ref: string;
source: CoachResultSource;
shape: CoachResultShape;
titleKey: string;
title: string;
/** The full row count, before any trim. */
rowCount: number;
chartKind: CoachChartSpec["kind"] | null;
/** The answer referenced it (shown expanded) or it sits under "Data used". */
displayed: boolean;
/** Set when the table was copied from an earlier message of the thread. */
reusedFrom?: { messageId: string; ref: string };
}
/** Encrypted at rest; on the wire only to the owner. */
export interface CoachResultTable extends CoachResultMeta {
columns: CoachResultColumn[];
/** Row-major, at most 400 rows. */
rows: CoachResultCell[][];
/** True when `rowCount` exceeds `rows.length`. */
truncated: boolean;
chart: CoachChartSpec | null;
}
/**
* One entry of `GET …/messages/{messageId}/results`: the table, or the
* reason it is not served. `module_disabled` — the domain's module is now
* off for the record; `unavailable` — the stored tables could not be read.
*/
export type CoachResultEntry =
| CoachResultTable
| { ref: string; withheld: "module_disabled" | "unavailable" };
// ── Method (v1.39.4) ────────────────────────────────────────────────────
// How the answer was reached: sources, windows, counts, aggregation. Never
// a health value.
export interface CoachMethodEntry {
domain: CoachStepDomain;
window?: CoachScopeWindow;
period?: CoachResultPeriod;
granularity?: CoachResultGranularity;
count?: number;
aggregation?: "mean" | "median" | "latest" | "sum" | "count" | "rate";
absent?: "no_data" | "outside_window" | "module_disabled";
}
export interface CoachMethod {
entries: CoachMethodEntry[];
/** Rendered on the server in the request locale. */
text: string;
}
// ── Follow-ups (v1.39.4) ────────────────────────────────────────────────
// Chips under the latest assistant reply. The label is always rendered by
// the server from a catalog; the model may at most pick a kind and domain.
export type CoachFollowUpKind =
| "widen_window"
| "previous_period"
| "year_ago"
| "as_chart"
| "as_table"
| "related_metric"
| "continue";
export interface CoachFollowUp {
/** `f1`..`f3`. */
id: string;
kind: CoachFollowUpKind;
labelKey: string;
label: string;
anchor?: {
ref: string;
domain: CoachStepDomain;
window?: CoachScopeWindow;
granularity?: CoachResultGranularity;
period?: CoachResultPeriod;
};
/** True when the chip is answered from a stored table, without a model. */
reuse: boolean;
origin: "server" | "model";
}
// ── Clarification (v1.39.4) ─────────────────────────────────────────────
// A question with choices. The question text is the assistant message
// itself, encrypted like any reply; only the choices ride here.
export interface CoachClarificationChoice {
id: string;
labelKey: string;
label: string;
value: { metric?: CoachScopeSource; window?: CoachScopeWindow };
}
export interface CoachClarification {
kind: "metric" | "window" | "context";
/** At most 4; metric choices are always ones the record holds. */
choices: CoachClarificationChoice[];
/** Whether a typed answer is welcome beside the choices. */
freeText: boolean;
}
/**
* v1.4.22 — Zod schema for one entry inside the Coach's evidence
* block. The model emits these as lines between the
@@ -399,6 +637,29 @@ export interface CoachProvenance {
* turn withheld nothing.
*/
unverifiedFigures?: number;
/**
* v1.39.4 — what the Coach read on this turn, one entry per tool call (or
* one `snapshot` step on the no-tools path). Catalog labels, domains,
* windows and counts only.
*/
steps?: CoachStep[];
/** v1.39.4 — how the answer was reached, as a server-rendered line. */
method?: CoachMethod;
/**
* v1.39.4 — the tables this turn produced, metadata only. The values are
* in `results_encrypted` and served by
* `GET /api/insights/chat/{id}/messages/{messageId}/results`.
*/
results?: CoachResultMeta[];
/** v1.39.4 — the chips offered under this reply. */
followUps?: CoachFollowUp[];
/** v1.39.4 — the choices, when this reply is a clarifying question. */
clarification?: CoachClarification;
/**
* v1.39.4 — the tool loop hit its round cap while the model still wanted
* to read, so the answer was forced. Absent otherwise.
*/
forcedFinal?: true;
}
/**