From cde7af3cf8ae21f97e665ec2ab2309cbcc789a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 10:33:13 +0200 Subject: [PATCH] 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. --- src/__tests__/coach-stream-ios-compat.test.ts | 106 ++++ .../stream-events-type-parity.test.ts | 71 +++ src/lib/ai/coach/stream-events.ts | 484 ++++++++++++++++++ src/lib/ai/coach/types.ts | 261 ++++++++++ 4 files changed, 922 insertions(+) create mode 100644 src/__tests__/coach-stream-ios-compat.test.ts create mode 100644 src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts create mode 100644 src/lib/ai/coach/stream-events.ts diff --git a/src/__tests__/coach-stream-ios-compat.test.ts b/src/__tests__/coach-stream-ios-compat.test.ts new file mode 100644 index 000000000..8235fb064 --- /dev/null +++ b/src/__tests__/coach-stream-ios-compat.test.ts @@ -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 } & 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 = 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"]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts b/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts new file mode 100644 index 000000000..bec015176 --- /dev/null +++ b/src/lib/ai/coach/__tests__/stream-events-type-parity.test.ts @@ -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 + >().toEqualTypeOf(); + }); + + it("each component the OpenAPI document names", () => { + expectTypeOf>().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + expectTypeOf< + z.infer + >().toEqualTypeOf(); + }); +}); diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts new file mode 100644 index 000000000..a6ddccff0 --- /dev/null +++ b/src/lib/ai/coach/stream-events.ts @@ -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; + +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: \\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.", + }); diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index ca2282dd4..9420d4b63 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -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; } /**