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 1/8] 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;
}
/**
From a8b3e5d7f4e160608925f64d77db911e5a2e111f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 10:36:27 +0200
Subject: [PATCH 2/8] feat(coach): store a turn's result tables encrypted
beside the message
A new nullable column, coach_messages.results_encrypted, holds the tables
of values a turn read as AES-GCM ciphertext; only their metadata rides the
plaintext provenance. readMessageResults serves them to the owner, each
table whole or withheld with a reason. The provenance restore learns the
steps, method, table metadata, chips, clarification and the forced-answer
marker, entry by entry against the wire schema.
The column is registered for key rotation, carried through both backup
formats (ciphertext in a disaster-recovery file, readable JSON in a
portable one) and goes with its row on conversation delete and wipe.
---
.../0360_coach_message_results/migration.sql | 14 +
prisma/schema.prisma | 6 +
scripts/rotate-encryption-key.ts | 12 +-
.../__tests__/persistence-results.test.ts | 339 ++++++++++++++++++
src/lib/ai/coach/persistence.ts | 185 ++++++++++
src/lib/crypto/encrypted-columns.ts | 2 +
src/lib/export/coach-backup.ts | 40 +++
src/lib/validations/backup.ts | 4 +
tests/integration/backup-round-trip.test.ts | 113 ++++++
9 files changed, 714 insertions(+), 1 deletion(-)
create mode 100644 prisma/migrations/0360_coach_message_results/migration.sql
create mode 100644 src/lib/ai/coach/__tests__/persistence-results.test.ts
diff --git a/prisma/migrations/0360_coach_message_results/migration.sql b/prisma/migrations/0360_coach_message_results/migration.sql
new file mode 100644
index 000000000..91969be4f
--- /dev/null
+++ b/prisma/migrations/0360_coach_message_results/migration.sql
@@ -0,0 +1,14 @@
+-- The tables of values a Coach turn read, stored with the assistant message
+-- that showed them: a JSON array of result tables, AES-256-GCM encrypted in
+-- the same Bytes codec as the message body. The values are health data, so
+-- they never sit in the plaintext `metric_source_json`, which carries only the
+-- tables' metadata (ref, source, shape, row count).
+--
+-- A stored table keeps the values the answer was given with; a later edit or
+-- sync does not rewrite what an earlier answer showed. The column goes with
+-- its row, so deleting a conversation or wiping the account removes it by
+-- the existing cascade.
+--
+-- Additive only: a nullable column, no table rewrite, no data change.
+
+ALTER TABLE "coach_messages" ADD COLUMN "results_encrypted" BYTEA;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e24bf462a..21104452d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -6221,6 +6221,12 @@ model CoachMessage {
// nullable: user turns, refusals, and pre-feature rows carry NULL.
tokensUsed Int? @map("tokens_used")
model String?
+ // v1.39.4 — the tables of values this turn read (JSON `CoachResultTable[]`,
+ // at most 128 KB), AES-256-GCM encrypted like `encryptedContent`. Health
+ // values, so they never ride the plaintext `metricSourceJson`, which holds
+ // only the tables' metadata. Read through `readMessageResults` by the
+ // owner-narrowed results endpoint; NULL on every turn without a table.
+ resultsEncrypted Bytes? @map("results_encrypted")
createdAt DateTime @default(now()) @map("created_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts
index da64e9060..dafb6b037 100644
--- a/scripts/rotate-encryption-key.ts
+++ b/scripts/rotate-encryption-key.ts
@@ -418,7 +418,8 @@ async function main() {
}
// ───── Coach (Bytes columns) ─────
- // "encryptedContent" "summaryEncrypted" "titleEncrypted" "factEncrypted"
+ // "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted"
+ // "factEncrypted"
results.push(
await rotateBytesColumn(
"CoachMessage",
@@ -426,6 +427,15 @@ async function main() {
prisma.coachMessage,
),
);
+ // v1.39.4 — the tables of values a turn read. NULL on every turn without a
+ // table, which `rotateBytesColumn` skips.
+ results.push(
+ await rotateBytesColumn(
+ "CoachMessage",
+ "resultsEncrypted",
+ prisma.coachMessage,
+ ),
+ );
results.push(
await rotateBytesColumn(
"CoachConversation",
diff --git a/src/lib/ai/coach/__tests__/persistence-results.test.ts b/src/lib/ai/coach/__tests__/persistence-results.test.ts
new file mode 100644
index 000000000..bdbc97589
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/persistence-results.test.ts
@@ -0,0 +1,339 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * v1.39.4 — the dialog fields of the provenance survive a reload, malformed
+ * entries are dropped one by one, the tables are sealed into their own
+ * column, and the owner-narrowed read serves each table whole or names why
+ * it does not.
+ */
+const txCreate = {
+ coachConversation: { update: vi.fn() },
+ coachMessage: { create: vi.fn() },
+};
+const findFirst = vi.fn();
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ $transaction: vi.fn(async (cb: (tx: typeof txCreate) => Promise) =>
+ cb(txCreate),
+ ),
+ coachMessage: { findFirst: (...args: unknown[]) => findFirst(...args) },
+ },
+}));
+vi.mock("../bytes-codec", () => ({
+ encryptToBytes: vi.fn((text: string) =>
+ new TextEncoder().encode(`enc:${text}`),
+ ),
+ decryptFromBytes: vi.fn((bytes: Uint8Array) => {
+ const text = new TextDecoder().decode(bytes);
+ if (!text.startsWith("enc:")) throw new Error("bad ciphertext");
+ return text.slice(4);
+ }),
+}));
+
+import {
+ RESULTS_MAX_BYTES,
+ appendMessage,
+ readMessageResults,
+} from "../persistence";
+import type {
+ CoachProvenance,
+ CoachResultMeta,
+ CoachResultTable,
+} from "../types";
+
+const META: CoachResultMeta = {
+ ref: "r1",
+ source: {
+ tool: "get_metric_series",
+ domain: "bp",
+ window: "last30days",
+ period: "current",
+ granularity: "day",
+ },
+ shape: "timeSeries",
+ titleKey: "coach.result.title.metricByPeriod",
+ title: "Blood pressure by day",
+ rowCount: 2,
+ chartKind: "line",
+ displayed: true,
+};
+
+const TABLE: CoachResultTable = {
+ ...META,
+ columns: [
+ {
+ key: "period",
+ kind: "period",
+ labelKey: "coach.result.column.day",
+ label: "Day",
+ },
+ {
+ key: "sys",
+ kind: "number",
+ labelKey: "coach.result.column.systolic",
+ label: "Systolic",
+ unit: "mmHg",
+ decimals: 0,
+ },
+ ],
+ rows: [
+ ["2026-09-01", 128],
+ ["2026-09-02", null],
+ ],
+ truncated: false,
+ chart: { kind: "line", x: "period", series: ["sys"] },
+};
+
+const SLEEP_META: CoachResultMeta = {
+ ...META,
+ ref: "r2",
+ source: { ...META.source, domain: "sleep", tool: "get_sleep" },
+ title: "Sleep by night",
+};
+
+const FULL: CoachProvenance = {
+ windows: ["last30days"],
+ metrics: ["bp"],
+ steps: [
+ {
+ id: "s1",
+ tool: "get_metric_series",
+ labelKey: "coach.step.read",
+ label: "Blood pressure, last 30 days",
+ domain: "bp",
+ window: "last30days",
+ status: "done",
+ count: 42,
+ resultRef: "r1",
+ },
+ ],
+ method: {
+ entries: [
+ { domain: "bp", window: "last30days", count: 42, aggregation: "mean" },
+ ],
+ text: "Blood pressure, last 30 days: 42 readings",
+ },
+ results: [META],
+ followUps: [
+ {
+ id: "f1",
+ kind: "previous_period",
+ labelKey: "coach.followUp.previousPeriod",
+ label: "Compare with the period before",
+ anchor: { ref: "r1", domain: "bp", window: "last30days" },
+ reuse: false,
+ origin: "server",
+ },
+ ],
+ clarification: {
+ kind: "window",
+ choices: [
+ {
+ id: "c1",
+ labelKey: "coach.clarify.window.last30days",
+ label: "Last 30 days",
+ value: { window: "last30days" },
+ },
+ ],
+ freeText: true,
+ },
+ forcedFinal: true,
+};
+
+function stubEchoCreate(overrideJson?: string): void {
+ txCreate.coachConversation.update.mockResolvedValue({});
+ txCreate.coachMessage.create.mockImplementation(
+ async (arg: { data: { metricSourceJson: string | null } }) => ({
+ id: "m1",
+ role: "assistant",
+ createdAt: new Date("2026-09-26T00:00:00.000Z"),
+ metricSourceJson: overrideJson ?? arg.data.metricSourceJson,
+ providerType: null,
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ }),
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("appendMessage — the dialog fields", () => {
+ it("restores steps, method, table metadata, chips, clarification and the forced marker", async () => {
+ stubEchoCreate();
+ const out = await appendMessage({
+ conversationId: "c1",
+ role: "assistant",
+ content: "reply",
+ metricSource: FULL,
+ });
+ expect(out.metricSource).toEqual(FULL);
+ });
+
+ it("drops a malformed entry and keeps its well-formed siblings", async () => {
+ stubEchoCreate(
+ JSON.stringify({
+ windows: [],
+ metrics: [],
+ steps: [FULL.steps![0], { id: "s2", status: "exploded" }],
+ results: [{ ref: "r9" }, META],
+ followUps: [{ ...FULL.followUps![0], kind: "free_text" }],
+ method: { entries: "nope", text: "x" },
+ clarification: { kind: "metric", choices: [], freeText: "yes" },
+ forcedFinal: "true",
+ }),
+ );
+ const out = await appendMessage({
+ conversationId: "c1",
+ role: "assistant",
+ content: "reply",
+ metricSource: { windows: [], metrics: [] },
+ });
+ expect(out.metricSource).toEqual({
+ windows: [],
+ metrics: [],
+ steps: FULL.steps,
+ results: [META],
+ });
+ });
+
+ it("strips a key the stored blob carries that the contract does not know", async () => {
+ stubEchoCreate(
+ JSON.stringify({
+ windows: [],
+ metrics: [],
+ results: [{ ...META, rows: [["2026-09-01", 128]] }],
+ }),
+ );
+ const out = await appendMessage({
+ conversationId: "c1",
+ role: "assistant",
+ content: "reply",
+ metricSource: { windows: [], metrics: [] },
+ });
+ // Values never ride the plaintext provenance, even when a blob has them.
+ expect(out.metricSource?.results?.[0]).not.toHaveProperty("rows");
+ });
+});
+
+describe("appendMessage — the tables", () => {
+ it("seals the tables into their own column and leaves them out of the provenance", async () => {
+ stubEchoCreate();
+ await appendMessage({
+ conversationId: "c1",
+ role: "assistant",
+ content: "reply",
+ metricSource: { windows: [], metrics: [], results: [META] },
+ results: [TABLE],
+ });
+ const data = txCreate.coachMessage.create.mock.calls[0][0].data;
+ expect(new TextDecoder().decode(data.resultsEncrypted)).toBe(
+ `enc:${JSON.stringify([TABLE])}`,
+ );
+ expect(data.metricSourceJson).not.toContain("2026-09-01");
+ });
+
+ it("writes NULL on a turn without tables", async () => {
+ stubEchoCreate();
+ await appendMessage({
+ conversationId: "c1",
+ role: "assistant",
+ content: "reply",
+ results: [],
+ });
+ const data = txCreate.coachMessage.create.mock.calls[0][0].data;
+ expect(data.resultsEncrypted).toBeNull();
+ });
+
+ it("drops whole tables from the end until the message fits the ceiling", async () => {
+ stubEchoCreate();
+ const big: CoachResultTable = {
+ ...TABLE,
+ ref: "r2",
+ rows: Array.from({ length: 400 }, () => ["x".repeat(400), 1]),
+ };
+ await appendMessage({
+ conversationId: "c1",
+ role: "assistant",
+ content: "reply",
+ results: [TABLE, big],
+ });
+ const data = txCreate.coachMessage.create.mock.calls[0][0].data;
+ const json = new TextDecoder().decode(data.resultsEncrypted).slice(4);
+ expect(Buffer.byteLength(json)).toBeLessThanOrEqual(RESULTS_MAX_BYTES);
+ expect(JSON.parse(json)).toEqual([TABLE]);
+ });
+});
+
+describe("readMessageResults", () => {
+ function row(results: CoachResultMeta[], bytes: Uint8Array | null) {
+ return {
+ metricSourceJson: JSON.stringify({ windows: [], metrics: [], results }),
+ resultsEncrypted: bytes,
+ };
+ }
+ const sealed = (tables: unknown) =>
+ new TextEncoder().encode(`enc:${JSON.stringify(tables)}`);
+
+ it("narrows to the owner through the conversation", async () => {
+ findFirst.mockResolvedValue(null);
+ await expect(readMessageResults("u1", "c1", "m1")).resolves.toBeNull();
+ expect(findFirst.mock.calls[0][0].where).toEqual({
+ id: "m1",
+ conversationId: "c1",
+ conversation: { userId: "u1" },
+ });
+ });
+
+ it("serves every table the provenance lists, in its order", async () => {
+ findFirst.mockResolvedValue(row([META], sealed([TABLE])));
+ await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
+ TABLE,
+ ]);
+ });
+
+ it("withholds a table whose domain is switched off, without decrypting it", async () => {
+ findFirst.mockResolvedValue(
+ row([META, SLEEP_META], sealed([TABLE, { ...TABLE, ...SLEEP_META }])),
+ );
+ const out = await readMessageResults(
+ "u1",
+ "c1",
+ "m1",
+ (domain) => domain === "sleep",
+ );
+ expect(out).toEqual([TABLE, { ref: "r2", withheld: "module_disabled" }]);
+
+ findFirst.mockResolvedValue(row([SLEEP_META], sealed([TABLE])));
+ const { decryptFromBytes } = await import("../bytes-codec");
+ vi.mocked(decryptFromBytes).mockClear();
+ await readMessageResults("u1", "c1", "m1", () => true);
+ expect(decryptFromBytes).not.toHaveBeenCalled();
+ });
+
+ it("names a table it cannot read rather than serving nothing", async () => {
+ findFirst.mockResolvedValue(
+ row([META], new TextEncoder().encode("garbage")),
+ );
+ await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
+ { ref: "r1", withheld: "unavailable" },
+ ]);
+
+ // A ref the ciphertext does not carry (dropped at the size ceiling).
+ findFirst.mockResolvedValue(row([META, SLEEP_META], sealed([TABLE])));
+ await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([
+ TABLE,
+ { ref: "r2", withheld: "unavailable" },
+ ]);
+ });
+
+ it("answers an empty list for a message without tables", async () => {
+ findFirst.mockResolvedValue({
+ metricSourceJson: null,
+ resultsEncrypted: null,
+ });
+ await expect(readMessageResults("u1", "c1", "m1")).resolves.toEqual([]);
+ });
+});
diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts
index 85f0fc1b1..2f6f764e8 100644
--- a/src/lib/ai/coach/persistence.ts
+++ b/src/lib/ai/coach/persistence.ts
@@ -10,8 +10,18 @@
* provenance (window names, metric tags, sample counts) and never raw
* values, so it can be queried without decryption for analytics.
*/
+import type { z } from "zod/v4";
+
import { prisma } from "@/lib/db";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
+import {
+ coachClarificationSchema,
+ coachFollowUpSchema,
+ coachMethodSchema,
+ coachResultMetaSchema,
+ coachResultTableSchema,
+ coachStepSchema,
+} from "./stream-events";
import { COACH_CONVERSATION_TITLE_MAX } from "./types";
import {
isCheckupIntervalId,
@@ -20,12 +30,20 @@ import {
} from "./suggest-action";
import type {
+ CoachClarification,
CoachConversationAttachmentDTO,
CoachConversationDTO,
CoachConversationDetailDTO,
+ CoachFollowUp,
CoachMessageDTO,
CoachMessageRole,
+ CoachMethod,
CoachProvenance,
+ CoachResultEntry,
+ CoachResultMeta,
+ CoachResultTable,
+ CoachStep,
+ CoachStepDomain,
CoachSuggestion,
} from "./types";
@@ -56,6 +74,9 @@ export function summariseTitle(input: string): string {
return `${cut.trimEnd()}…`;
}
+/** v1.39.4 — at most this many tables per message (`r1`..`r6`). */
+const MAX_RESULTS_PER_MESSAGE = 6;
+
function provenanceToJson(provenance: CoachProvenance | null): string | null {
if (!provenance) return null;
return JSON.stringify(provenance);
@@ -162,6 +183,53 @@ function restoreToolCalls(raw: unknown): CoachProvenance["toolCalls"] {
return cleaned.length > 0 ? cleaned : undefined;
}
+/**
+ * v1.39.4 — restore an array field of the provenance entry by entry against
+ * the wire schema. A malformed entry is dropped, not the whole field; an
+ * empty or absent array restores to undefined. The schemas strip keys they do
+ * not know, so a stored blob can never widen what a reader sees.
+ */
+function restoreEach(
+ raw: unknown,
+ schema: z.ZodType,
+ cap: number,
+): T[] | undefined {
+ if (!Array.isArray(raw)) return undefined;
+ const cleaned: T[] = [];
+ for (const item of raw.slice(0, cap)) {
+ const parsed = schema.safeParse(item);
+ if (parsed.success) cleaned.push(parsed.data);
+ }
+ return cleaned.length > 0 ? cleaned : undefined;
+}
+
+/** The live steps a turn persisted (at most 12 per turn). */
+function restoreSteps(raw: unknown): CoachStep[] | undefined {
+ return restoreEach(raw, coachStepSchema, 12);
+}
+
+/** The method line; dropped whole when it does not parse. */
+function restoreMethod(raw: unknown): CoachMethod | undefined {
+ const parsed = coachMethodSchema.safeParse(raw);
+ return parsed.success ? parsed.data : undefined;
+}
+
+/** The tables' metadata (at most 6 per message). No values live here. */
+function restoreResultMetas(raw: unknown): CoachResultMeta[] | undefined {
+ return restoreEach(raw, coachResultMetaSchema, MAX_RESULTS_PER_MESSAGE);
+}
+
+/** The chips offered under the reply (at most 3). */
+function restoreFollowUps(raw: unknown): CoachFollowUp[] | undefined {
+ return restoreEach(raw, coachFollowUpSchema, 3);
+}
+
+/** The clarification choices; dropped whole when they do not parse. */
+function restoreClarification(raw: unknown): CoachClarification | undefined {
+ const parsed = coachClarificationSchema.safeParse(raw);
+ return parsed.success ? parsed.data : undefined;
+}
+
function provenanceFromJson(raw: string | null): CoachProvenance | null {
if (!raw) return null;
try {
@@ -249,6 +317,14 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
const suggestion = restoreSuggestion(parsed.suggestion);
const suggestedAction = restoreSuggestedAction(parsed.suggestedAction);
const toolCalls = restoreToolCalls(parsed.toolCalls);
+ // v1.39.4 — the dialog fields: steps, method, table metadata, chips, the
+ // clarification choices, and the forced-answer marker.
+ const steps = restoreSteps(parsed.steps);
+ const method = restoreMethod(parsed.method);
+ const results = restoreResultMetas(parsed.results);
+ const followUps = restoreFollowUps(parsed.followUps);
+ const clarification = restoreClarification(parsed.clarification);
+ const forcedFinal = parsed.forcedFinal === true;
return {
windows,
metrics,
@@ -259,6 +335,12 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null {
...(suggestedAction ? { suggestedAction } : {}),
...(toolCalls ? { toolCalls } : {}),
...(unverifiedFigures !== undefined ? { unverifiedFigures } : {}),
+ ...(steps ? { steps } : {}),
+ ...(method ? { method } : {}),
+ ...(results ? { results } : {}),
+ ...(followUps ? { followUps } : {}),
+ ...(clarification ? { clarification } : {}),
+ ...(forcedFinal ? { forcedFinal: true as const } : {}),
};
} catch {
return null;
@@ -295,6 +377,39 @@ export interface AppendMessageParams {
*/
tokensUsed?: number | null;
model?: string | null;
+ /**
+ * v1.39.4 — the tables of values this turn read. Encrypted into
+ * `resultsEncrypted`; their metadata rides `metricSource.results`. Omitted
+ * or empty on every turn without a table.
+ */
+ results?: CoachResultTable[];
+}
+
+/**
+ * v1.39.4 — the at-rest ceiling for one message's tables, as JSON before
+ * encryption. Tables arrive trimmed to 400 rows; a message whose tables still
+ * exceed this keeps the leading tables that fit, and a reader is told the
+ * rest are unavailable rather than handed a partial table.
+ */
+export const RESULTS_MAX_BYTES = 128 * 1024;
+
+/**
+ * Serialise a turn's tables for the ciphertext column, or null when there are
+ * none. Drops whole tables from the end until the JSON fits the ceiling.
+ */
+function resultsToBytes(
+ results: CoachResultTable[] | undefined,
+): Uint8Array | null {
+ if (!results || results.length === 0) return null;
+ const kept = results.slice(0, MAX_RESULTS_PER_MESSAGE);
+ while (kept.length > 0) {
+ const json = JSON.stringify(kept);
+ if (Buffer.byteLength(json, "utf8") <= RESULTS_MAX_BYTES) {
+ return encryptToBytes(json);
+ }
+ kept.pop();
+ }
+ return null;
}
/**
@@ -417,6 +532,7 @@ export async function appendMessage(
promptVersion: params.promptVersion ?? null,
tokensUsed: params.tokensUsed ?? null,
model: params.model ?? null,
+ resultsEncrypted: resultsToBytes(params.results),
},
});
await tx.coachConversation.update({
@@ -547,6 +663,9 @@ export async function fetchConversationWithMessages(
// changing the oldest->newest contract the client renders.
orderBy: { createdAt: "desc" },
take: CONVERSATION_MESSAGE_DETAIL_CAP,
+ // v1.39.4 — the tables are read lazily, one message at a time, through
+ // `readMessageResults`; the detail read never loads their ciphertext.
+ omit: { resultsEncrypted: true },
},
// v1.29.x (S7) — the LIVE attachment set (join → document label columns
// only; the encrypted body is untouched), ordered by attach time. Always
@@ -601,6 +720,72 @@ export async function fetchConversationWithMessages(
};
}
+/**
+ * v1.39.4 — the stored tables of one assistant message, for its owner.
+ *
+ * Narrowed like the conversation read: the message must sit in a
+ * conversation `userId` owns, and a foreign or missing id is null (the route
+ * answers 404 for both, so existence never leaks across accounts).
+ *
+ * One entry per table the message's provenance lists, in that order. A
+ * table is served whole or withheld with a reason, never partially:
+ * - `module_disabled` — `withhold(domain)` said the table's domain is
+ * switched off for the record now. Such a table is not even decrypted.
+ * - `unavailable` — the ciphertext did not decrypt, did not parse, or did
+ * not carry that ref (a table dropped at the size ceiling). Fail closed
+ * and say so, rather than serve nothing silently.
+ */
+export async function readMessageResults(
+ userId: string,
+ conversationId: string,
+ messageId: string,
+ withhold: (domain: CoachStepDomain) => boolean = () => false,
+): Promise {
+ const row = await prisma.coachMessage.findFirst({
+ where: {
+ id: messageId,
+ conversationId,
+ conversation: { userId },
+ },
+ select: { metricSourceJson: true, resultsEncrypted: true },
+ });
+ if (!row) return null;
+
+ const metas = provenanceFromJson(row.metricSourceJson)?.results ?? [];
+ if (metas.length === 0) return [];
+
+ const served = metas.filter((meta) => !withhold(meta.source.domain));
+ const tables =
+ served.length > 0 ? decryptResultTables(row.resultsEncrypted) : null;
+
+ return metas.map((meta): CoachResultEntry => {
+ if (withhold(meta.source.domain)) {
+ return { ref: meta.ref, withheld: "module_disabled" };
+ }
+ const table = tables?.get(meta.ref);
+ return table ?? { ref: meta.ref, withheld: "unavailable" };
+ });
+}
+
+/** Decrypt and parse a message's tables by ref; null when unreadable. */
+function decryptResultTables(
+ bytes: Uint8Array | null,
+): Map | null {
+ if (!bytes || bytes.byteLength === 0) return null;
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(decryptFromBytes(bytes));
+ } catch {
+ return null;
+ }
+ const tables = restoreEach(
+ parsed,
+ coachResultTableSchema,
+ MAX_RESULTS_PER_MESSAGE,
+ );
+ return new Map((tables ?? []).map((table) => [table.ref, table]));
+}
+
export interface ListConversationsParams {
userId: string;
cursor?: string | null;
diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts
index 61c335556..008d19e5f 100644
--- a/src/lib/crypto/encrypted-columns.ts
+++ b/src/lib/crypto/encrypted-columns.ts
@@ -215,6 +215,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
// ───── Coach (Bytes columns) ─────
{ model: "CoachMessage", field: "encryptedContent", kind: "bytes" },
+ // v1.39.4 — the tables of values a turn read, JSON under the same codec.
+ { model: "CoachMessage", field: "resultsEncrypted", kind: "bytes" },
{ model: "CoachConversation", field: "summaryEncrypted", kind: "bytes" },
// v1.39.3 — the title: the opening words of the first message, or a rename.
{ model: "CoachConversation", field: "titleEncrypted", kind: "bytes" },
diff --git a/src/lib/export/coach-backup.ts b/src/lib/export/coach-backup.ts
index a373843e6..f5c64ffc2 100644
--- a/src/lib/export/coach-backup.ts
+++ b/src/lib/export/coach-backup.ts
@@ -79,6 +79,17 @@ export interface CoachMessageBackupEntry {
promptVersion: string | null;
tokensUsed: number | null;
model: string | null;
+ /**
+ * v1.39.4 — the tables of values the turn read, as ciphertext in base64.
+ * Present on a disaster-recovery payload only; null on a turn without one.
+ */
+ resultsEncrypted?: string | null;
+ /**
+ * v1.39.4 — the same tables as readable JSON (a `CoachResultTable[]`
+ * array), on a portable payload only; null on a turn without one. The
+ * unreadable-row marker stands in for tables that did not decrypt.
+ */
+ resultsJson?: string | null;
createdAt: string;
}
@@ -155,6 +166,7 @@ const COACH_CONVERSATION_BACKUP_SELECT = {
promptVersion: true,
tokensUsed: true,
model: true,
+ resultsEncrypted: true,
createdAt: true,
},
},
@@ -248,6 +260,14 @@ export async function buildCoachBackupSection(
promptVersion: message.promptVersion,
tokensUsed: message.tokensUsed,
model: message.model,
+ // v1.39.4 — the tables follow the same contract as the prose.
+ ...(disasterRecovery
+ ? {
+ resultsEncrypted: message.resultsEncrypted
+ ? Buffer.from(message.resultsEncrypted).toString("base64")
+ : null,
+ }
+ : { resultsJson: decryptTurnSoft(message.resultsEncrypted) }),
createdAt: message.createdAt.toISOString(),
})),
attachments: row.attachments.map((attachment) => ({
@@ -318,6 +338,8 @@ export type RestoredCoachMessage = Pick<
| "promptVersion"
| "tokensUsed"
| "model"
+ | "resultsEncrypted"
+ | "resultsJson"
>
>;
@@ -401,6 +423,7 @@ export async function restoreCoachData(
promptVersion: message.promptVersion ?? null,
tokensUsed: message.tokensUsed ?? null,
model: message.model ?? null,
+ resultsEncrypted: resolveResultsBytes(message),
createdAt: new Date(message.createdAt),
})),
},
@@ -457,6 +480,23 @@ function resolveTurnBytes(
return encryptToBytes(message.content ?? "");
}
+/**
+ * v1.39.4 — a turn's stored tables, from either end of the contract. A
+ * disaster-recovery file carries the ciphertext; a portable file carries the
+ * JSON, sealed here under the target instance's key. Neither end writes a
+ * zero-byte ciphertext for a turn without tables.
+ */
+function resolveResultsBytes(
+ message: RestoredCoachMessage,
+): Uint8Array | null {
+ if (message.resultsEncrypted !== undefined) {
+ return message.resultsEncrypted === null
+ ? null
+ : decodeBase64(message.resultsEncrypted);
+ }
+ return message.resultsJson ? encryptToBytes(message.resultsJson) : null;
+}
+
function resolveTitleBytes(
conversation: RestoredCoachConversation,
): Uint8Array {
diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts
index 51c02fb12..e188697fc 100644
--- a/src/lib/validations/backup.ts
+++ b/src/lib/validations/backup.ts
@@ -1354,6 +1354,10 @@ const coachMessageBackupSchema = z
promptVersion: z.string().nullable().optional(),
tokensUsed: z.number().int().nullable().optional(),
model: z.string().nullable().optional(),
+ // v1.39.4 — the turn's tables: ciphertext on a disaster-recovery file,
+ // readable JSON on a portable one, absent on every file written before.
+ resultsEncrypted: base64BytesSchema.nullable().optional(),
+ resultsJson: z.string().nullable().optional(),
createdAt: isoDateTime,
})
.passthrough();
diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts
index 6f4cc1117..8f50d291b 100644
--- a/tests/integration/backup-round-trip.test.ts
+++ b/tests/integration/backup-round-trip.test.ts
@@ -117,6 +117,46 @@ const METRIC_SEALED_NOTE = "right hand, rested";
const COACH_USER_TURN = "my readings look higher this week, is that real?";
const COACH_ASSISTANT_TURN =
"the last seven mornings average 4 mmHg above the fortnight before";
+/** The table of values the assistant turn read: ciphertext at rest. */
+const COACH_RESULTS_JSON = JSON.stringify([
+ {
+ ref: "r1",
+ source: {
+ tool: "get_metric_series",
+ domain: "bp",
+ window: "last7days",
+ period: "current",
+ granularity: "day",
+ },
+ shape: "timeSeries",
+ titleKey: "coach.result.title.metricByPeriod",
+ title: "Blood pressure by day",
+ rowCount: 2,
+ chartKind: "line",
+ displayed: true,
+ columns: [
+ {
+ key: "period",
+ kind: "period",
+ labelKey: "coach.result.column.day",
+ label: "Day",
+ },
+ {
+ key: "sys",
+ kind: "number",
+ labelKey: "coach.result.column.systolic",
+ label: "Systolic",
+ unit: "mmHg",
+ },
+ ],
+ rows: [
+ ["2026-07-19", 131],
+ ["2026-07-20", null],
+ ],
+ truncated: false,
+ chart: { kind: "line", x: "period", series: ["sys"] },
+ },
+]);
const DOSE_CHANGE_NOTE = "titration note, encrypted at rest";
const EXTRACTED_FACT_SPAN = "Ferritin 91 ng/mL (30 - 400)";
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
@@ -926,6 +966,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise {
{
role: "assistant",
encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
+ resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
providerType: "anthropic",
model: "claude-opus-5",
tokensUsed: 812,
@@ -1578,6 +1619,9 @@ describe("every model the plan claims two-ended survives a real restore", () =>
content: decryptFromBytes(message.encryptedContent),
model: message.model,
tokensUsed: message.tokensUsed,
+ results: message.resultsEncrypted
+ ? decryptFromBytes(message.resultsEncrypted)
+ : null,
})),
attachments: thread.attachments.map((a) => a.documentId),
})),
@@ -1595,12 +1639,14 @@ describe("every model the plan claims two-ended survives a real restore", () =>
content: COACH_USER_TURN,
model: null,
tokensUsed: null,
+ results: null,
},
{
role: "assistant",
content: COACH_ASSISTANT_TURN,
model: "claude-opus-5",
tokensUsed: 812,
+ results: COACH_RESULTS_JSON,
},
],
attachments: [],
@@ -1617,6 +1663,7 @@ describe("every model the plan claims two-ended survives a real restore", () =>
content: "What does the ferritin mean?",
model: null,
tokensUsed: null,
+ results: null,
},
],
attachments: [vaultDocument.id],
@@ -2697,6 +2744,72 @@ describe("every model the plan claims two-ended survives a real restore", () =>
* side by side: a dropped row and a nulled pointer, each named in the report,
* and a restore that still answers 200.
*/
+ /**
+ * The Coach's stored tables through a portable file.
+ *
+ * A disaster-recovery file carries them as ciphertext (the round trip at
+ * the top of this file). A portable file has to carry them readable, like
+ * the prose they sit beside, and the restore has to seal them again under
+ * the target's key. A restore that dropped the readable JSON would bring
+ * the thread back with every answer's table missing and nothing to say so.
+ */
+ it("carries a turn's tables readable in a portable file and seals them again on restore", async () => {
+ const prisma = getPrismaClient();
+ await seedAdminSession(prisma);
+ await createOwner(prisma);
+ await prisma.coachConversation.create({
+ data: {
+ userId: OWNER_ID,
+ titleEncrypted: encryptToBytes("How is my blood pressure trending?"),
+ documentScoped: false,
+ messages: {
+ create: [
+ {
+ role: "assistant",
+ encryptedContent: encryptToBytes(COACH_ASSISTANT_TURN),
+ resultsEncrypted: encryptToBytes(COACH_RESULTS_JSON),
+ createdAt: AT("2026-07-20T09:59:00.000Z"),
+ },
+ ],
+ },
+ },
+ });
+
+ const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
+ purpose: "portable-export",
+ });
+ const [turn] = payload.coachConversations[0].messages;
+ expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON });
+ expect(turn).not.toHaveProperty("resultsEncrypted");
+
+ await prisma.user.delete({ where: { id: OWNER_ID } });
+ await createOwner(prisma);
+ const backup = await prisma.dataBackup.create({
+ data: {
+ userId: OWNER_ID,
+ type: "TWO_ENDED_ROUND_TRIP",
+ data: encrypt(JSON.stringify(payload)),
+ },
+ });
+ const response = await POST(
+ new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ confirm: "RESTORE" }),
+ }) as never,
+ { params: Promise.resolve({ id: backup.id }) },
+ );
+ expect(response.status, JSON.stringify(await response.json())).toBe(200);
+
+ const restored = await prisma.coachMessage.findFirstOrThrow({
+ where: { conversation: { userId: OWNER_ID } },
+ });
+ expect(restored.resultsEncrypted).not.toBeNull();
+ expect(decryptFromBytes(restored.resultsEncrypted!)).toBe(
+ COACH_RESULTS_JSON,
+ );
+ });
+
it("drops and names a filing and a commitment a truncated file cannot resolve", async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
From 742ef2c9b01794e49e990ca004c98db843bddc44 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 10:37:40 +0200
Subject: [PATCH 3/8] feat(coach): report tool calls as they start and settle
The retrieval loop takes onCallStart and onCallSettled callbacks, fired
for every call with a turn-wide index; a throwing callback is swallowed.
The trace carries each call's schema-validated arguments (never the raw
string, never persisted), the result says whether the answer was forced
at the round cap, and a seedCalls parameter is accepted but not read yet.
---
.../__tests__/tools-loop-callbacks.test.ts | 152 ++++++++++++++++++
.../tools-loop-miss-payloads.test.ts | 4 +-
src/lib/ai/coach/__tests__/tools-loop.test.ts | 4 +-
src/lib/ai/coach/tools/definitions.ts | 38 +++++
src/lib/ai/coach/tools/executor.ts | 6 +
src/lib/ai/coach/tools/loop.ts | 64 +++++++-
6 files changed, 263 insertions(+), 5 deletions(-)
create mode 100644 src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts
diff --git a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts
new file mode 100644
index 000000000..f5f0cc3c4
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts
@@ -0,0 +1,152 @@
+/**
+ * v1.39.4 — the loop's progress seam: every call that starts settles, the
+ * trace keeps only schema-validated arguments, and a forced answer at the
+ * round cap is reported as such.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
+
+const executeCoachTool = vi.fn<(args?: unknown) => Promise>();
+vi.mock("@/lib/ai/coach/tools/executor", () => ({
+ executeCoachTool: (args: unknown) => executeCoachTool(args),
+}));
+const runRawCompletionWithFallback = vi.fn();
+vi.mock("@/lib/ai/provider-runner", () => ({
+ runRawCompletionWithFallback: (args: unknown) =>
+ runRawCompletionWithFallback(args),
+}));
+
+import { HARD_CAP, runCoachToolLoop } from "@/lib/ai/coach/tools/loop";
+import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
+
+type Call = { id: string; name: string; arguments: string };
+
+function round(content: string, toolCalls?: Call[]) {
+ return {
+ result: {
+ content,
+ tokensUsed: 5,
+ model: "mock",
+ providerType: "anthropic" as const,
+ ...(toolCalls
+ ? { toolCalls, finishReason: "tool_calls" as const }
+ : { finishReason: "stop" as const }),
+ },
+ workingProvider: { providerType: "anthropic" },
+ fallbackHops: [],
+ };
+}
+
+const baseArgs = {
+ userId: "u1",
+ providers: [],
+ system: "sys",
+ messages: [{ role: "user" as const, content: "q" }],
+ tools: COACH_TOOL_DEFS,
+};
+
+beforeEach(() => {
+ executeCoachTool.mockReset();
+ runRawCompletionWithFallback.mockReset();
+});
+
+describe("runCoachToolLoop — progress callbacks", () => {
+ it("settles every call that started, misses and invalid calls included, with turn-wide indexes", async () => {
+ executeCoachTool
+ .mockResolvedValueOnce({ present: true, data: {} })
+ .mockResolvedValueOnce({ present: false, reason: "invalid_arguments" })
+ .mockResolvedValueOnce({ present: false, reason: "unknown_tool" });
+ runRawCompletionWithFallback
+ .mockResolvedValueOnce(
+ round("", [
+ { id: "a", name: "get_metric_series", arguments: '{"metric":"bp"}' },
+ { id: "b", name: "get_sleep", arguments: "{not json" },
+ ]),
+ )
+ .mockResolvedValueOnce(
+ round("", [{ id: "c", name: "get_everything", arguments: "{}" }]),
+ )
+ .mockResolvedValueOnce(round("answer"));
+
+ const started: Array<[string, number]> = [];
+ const settled: Array<[string, boolean, number]> = [];
+ const out = await runCoachToolLoop({
+ ...baseArgs,
+ onCallStart: (call, index) => started.push([call.id, index]),
+ onCallSettled: (call, result, index) =>
+ settled.push([call.id, result.present, index]),
+ });
+
+ expect(started).toEqual([
+ ["a", 0],
+ ["b", 1],
+ ["c", 2],
+ ]);
+ expect([...settled].sort((x, y) => x[2] - y[2])).toEqual([
+ ["a", true, 0],
+ ["b", false, 1],
+ ["c", false, 2],
+ ]);
+ expect(out.forcedFinal).toBe(false);
+ });
+
+ it("keeps only schema-validated arguments on the trace", async () => {
+ executeCoachTool.mockResolvedValue({ present: false });
+ runRawCompletionWithFallback
+ .mockResolvedValueOnce(
+ round("", [
+ { id: "a", name: "get_metric_series", arguments: '{"metric":"bp"}' },
+ // Unknown key: the strict schema refuses it.
+ {
+ id: "b",
+ name: "get_metric_series",
+ arguments: '{"metric":"bp","userId":"someone-else"}',
+ },
+ { id: "c", name: "get_sleep", arguments: "{not json" },
+ ]),
+ )
+ .mockResolvedValueOnce(round("answer"));
+
+ const out = await runCoachToolLoop(baseArgs);
+ const byArgs = out.toolTrace.map((t) => t.args ?? null);
+ expect(byArgs).toContainEqual({ metric: "bp" });
+ expect(byArgs.filter((a) => a === null)).toHaveLength(2);
+ expect(JSON.stringify(out.toolTrace)).not.toContain("someone-else");
+ });
+
+ it("a throwing callback never breaks the turn", async () => {
+ executeCoachTool.mockResolvedValue({ present: true, data: {} });
+ runRawCompletionWithFallback
+ .mockResolvedValueOnce(
+ round("", [{ id: "a", name: "get_sleep", arguments: "{}" }]),
+ )
+ .mockResolvedValueOnce(round("answer"));
+ const out = await runCoachToolLoop({
+ ...baseArgs,
+ onCallStart: () => {
+ throw new Error("closed stream");
+ },
+ onCallSettled: () => {
+ throw new Error("closed stream");
+ },
+ });
+ expect(out.result.content).toBe("answer");
+ expect(out.toolTrace).toHaveLength(1);
+ });
+
+ it("reports a forced answer at the round cap", async () => {
+ executeCoachTool.mockResolvedValue({ present: false });
+ runRawCompletionWithFallback.mockImplementation(
+ (args: { params: { toolChoice?: string } }) =>
+ Promise.resolve(
+ args.params.toolChoice === "none"
+ ? round("forced")
+ : round("", [{ id: "x", name: "get_sleep", arguments: "{}" }]),
+ ),
+ );
+ const out = await runCoachToolLoop(baseArgs);
+ expect(out.rounds).toBe(HARD_CAP);
+ expect(out.forcedFinal).toBe(true);
+ });
+});
diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts
index c19d6e9b6..ed35284c4 100644
--- a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts
+++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts
@@ -79,7 +79,9 @@ describe("coach tool loop — which results reach the grounding guard", () => {
const loop = await runCoachToolLoop(baseArgs);
// The trace records that the tool ran and found nothing…
- expect(loop.toolTrace).toEqual([{ name: "get_sleep", present: false }]);
+ expect(loop.toolTrace).toEqual([
+ { name: "get_sleep", present: false, args: {} },
+ ]);
// …but the payload set the route grades against is empty, so the route's
// `activatingPayloads.length > 0` gate is false and the numeric grounding
// guard never runs for this turn.
diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts
index 1d1e2c899..973c1d751 100644
--- a/src/lib/ai/coach/__tests__/tools-loop.test.ts
+++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts
@@ -88,7 +88,7 @@ describe("runCoachToolLoop", () => {
expect(out.result.content).toBe("Your BP looks steady.");
expect(out.totalTokens).toBe(50); // summed across both rounds
expect(out.toolTrace).toEqual([
- { name: "get_metric_series", present: true },
+ { name: "get_metric_series", present: true, args: { metric: "bp" } },
]);
// v1.21.0 (P6) — the present result's payload is retained for the prose
// number-verifier.
@@ -278,7 +278,7 @@ describe("shared snapshot scope threading (D5-1)", () => {
expect(out.toolResults).toHaveLength(1);
expect(out.toolResults[0].available).toMatchObject({ count: 1597 });
expect(out.toolTrace).toEqual([
- { name: "get_glucose_panel", present: false },
+ { name: "get_glucose_panel", present: false, args: {} },
]);
});
diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts
index ee8e73c22..8207bac1f 100644
--- a/src/lib/ai/coach/tools/definitions.ts
+++ b/src/lib/ai/coach/tools/definitions.ts
@@ -98,6 +98,44 @@ export const getCycleArgsSchema = z.object({}).strict();
export const getCorrelationsArgsSchema = z.object({}).strict();
+/**
+ * v1.39.4 — each tool's argument schema, keyed by name. The record type makes
+ * a new tool name fail to compile until its schema is listed here.
+ */
+const COACH_TOOL_ARG_SCHEMAS: Record = {
+ get_metric_series: getMetricSeriesArgsSchema,
+ get_glucose_panel: getGlucosePanelArgsSchema,
+ get_sleep: getSleepArgsSchema,
+ get_medication_compliance: getMedicationComplianceArgsSchema,
+ get_labs: getLabsArgsSchema,
+ get_illness_recovery: getIllnessRecoveryArgsSchema,
+ get_workouts: getWorkoutsArgsSchema,
+ get_cycle: getCycleArgsSchema,
+ get_correlations: getCorrelationsArgsSchema,
+};
+
+/**
+ * v1.39.4 — a call's arguments as its schema validates them, or undefined
+ * when the name is unknown, the JSON does not parse, or the schema refuses
+ * it. Only this validated form ever rides the tool trace; the model's raw
+ * argument string never does.
+ */
+export function parseCoachToolArgs(
+ name: string,
+ rawArguments: string,
+): Record | undefined {
+ if (!isCoachToolName(name)) return undefined;
+ let raw: unknown;
+ try {
+ raw = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
+ } catch {
+ return undefined;
+ }
+ const parsed = COACH_TOOL_ARG_SCHEMAS[name].safeParse(raw);
+ if (!parsed.success) return undefined;
+ return parsed.data as Record;
+}
+
/**
* JSON-Schema parameter shapes handed to the provider. Kept hand-written
* (rather than generated from Zod) so the wire description the model reads is
diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts
index 099c8e1cb..dbeb060f8 100644
--- a/src/lib/ai/coach/tools/executor.ts
+++ b/src/lib/ai/coach/tools/executor.ts
@@ -99,6 +99,12 @@ export interface CoachToolResult {
export interface CoachToolTrace {
name: string;
present: boolean;
+ /**
+ * v1.39.4 — the call's arguments as their schema validated them; absent
+ * when they did not validate. Turn-internal (steps, method, chips): never
+ * persisted, the provenance keeps `{ name, present }` only.
+ */
+ args?: Record;
}
function pickSection(
diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts
index cd671c0b6..f657a25b4 100644
--- a/src/lib/ai/coach/tools/loop.ts
+++ b/src/lib/ai/coach/tools/loop.ts
@@ -26,9 +26,15 @@
import { annotate } from "@/lib/logging/context";
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
-import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types";
+import type {
+ AiMessage,
+ AiToolCall,
+ AiToolDef,
+ CompletionResult,
+} from "@/lib/ai/types";
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
+import { parseCoachToolArgs, type CoachToolName } from "./definitions";
import {
executeCoachTool,
type CoachToolResult,
@@ -81,6 +87,18 @@ export interface CoachToolLoopResult {
* one honest sentence the fix exists to make sayable.
*/
toolResults: CoachToolResult[];
+ /**
+ * v1.39.4 — true when the loop reached `HARD_CAP`: the model asked for
+ * tools in every round it was offered them, so the final answer was forced
+ * with `toolChoice: "none"` rather than given freely.
+ */
+ forcedFinal: boolean;
+}
+
+/** v1.39.4 — a tool call to run before the first model round. */
+interface CoachSeedCall {
+ name: CoachToolName;
+ args: Record;
}
export async function runCoachToolLoop(args: {
@@ -108,6 +126,26 @@ export async function runCoachToolLoop(args: {
* shared default holds.
*/
timeoutMs?: number;
+ /**
+ * v1.39.4 — fired as each tool call starts. `index` counts calls across
+ * the whole turn from 0, so it is stable for a step id. A throwing callback
+ * is swallowed: progress reporting never breaks the loop.
+ */
+ onCallStart?: (call: AiToolCall, index: number) => void;
+ /**
+ * v1.39.4 — fired once for every call that started, when its result is in:
+ * found, missed, invalid or failed alike (`executeCoachTool` never throws).
+ */
+ onCallSettled?: (
+ call: AiToolCall,
+ result: CoachToolResult,
+ index: number,
+ ) => void;
+ /**
+ * v1.39.4 — calls to run before round one, reusing the loop's own
+ * assistant-call and tool-result serialisation. Accepted, not read yet.
+ */
+ seedCalls?: ReadonlyArray;
}): Promise {
const {
userId,
@@ -121,6 +159,8 @@ export async function runCoachToolLoop(args: {
ledger,
signal,
timeoutMs,
+ onCallStart,
+ onCallSettled,
} = args;
const messages: AiMessage[] = [...args.messages];
@@ -130,6 +170,7 @@ export async function runCoachToolLoop(args: {
let workingProviderType = "";
const toolTrace: CoachToolTrace[] = [];
const toolResults: CoachToolResult[] = [];
+ let callCount = 0;
// Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool
// calls so the model must answer.
@@ -184,6 +225,7 @@ export async function runCoachToolLoop(args: {
rounds,
toolTrace,
toolResults,
+ forcedFinal: isForcedFinal,
};
}
@@ -197,6 +239,9 @@ export async function runCoachToolLoop(args: {
const results = await Promise.all(
calls.map(async (call) => {
+ const index = callCount;
+ callCount += 1;
+ notify(() => onCallStart?.(call, index));
const toolResult = await executeCoachTool({
userId,
name: call.name,
@@ -204,7 +249,13 @@ export async function runCoachToolLoop(args: {
fallbackWindow,
sharedScope,
});
- toolTrace.push({ name: call.name, present: toolResult.present });
+ notify(() => onCallSettled?.(call, toolResult, index));
+ const validArgs = parseCoachToolArgs(call.name, call.arguments);
+ toolTrace.push({
+ name: call.name,
+ present: toolResult.present,
+ ...(validArgs ? { args: validArgs } : {}),
+ });
// v1.21.0 (P6) — retain the payloads that carried figures for the
// post-hoc prose number-verifier (the union of numeric leaves grounds
// the figures the model may cite): a present result's `data`, or the
@@ -230,3 +281,12 @@ export async function runCoachToolLoop(args: {
// defensive guard.
throw new Error("coach tool loop exceeded hard cap without a final answer");
}
+
+/** Run a progress callback; a throw from it never reaches the loop. */
+function notify(callback: () => void): void {
+ try {
+ callback();
+ } catch {
+ // Progress is best-effort; the answer is not.
+ }
+}
From 8fcd2041726295a0849cd9cfdd69ab1ac568e45c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 10:43:15 +0200
Subject: [PATCH 4/8] feat(coach): seams for steps, tables, charts, method,
chips and clarification
Each later piece of the dialog gets its own module, answering nothing for
now: the step builder, the table projection and chart choice, the result
ref marks, the follow-up derivation, sentinel, resolution and continue
chip, the reuse turn, the clarification parse and answer, the method line,
and the four prompt addenda. The turn pipeline calls them where they
belong, so the frames go out in the documented order (step, token,
provenance, result, cards, clarification, followUps, done) and a blocked
turn drops its tables, chips and clarification. With every piece empty
the stream and the prompt stay byte for byte as before.
---
src/app/api/insights/chat/route.ts | 4 +
src/lib/ai/coach/clarify.ts | 34 ++++
src/lib/ai/coach/follow-ups/continue.ts | 15 ++
src/lib/ai/coach/follow-ups/derive.ts | 32 +++
src/lib/ai/coach/follow-ups/parse-sentinel.ts | 15 ++
src/lib/ai/coach/follow-ups/resolve.ts | 26 +++
src/lib/ai/coach/method.ts | 22 +++
src/lib/ai/coach/results/chart-spec.ts | 15 ++
src/lib/ai/coach/results/project.ts | 32 +++
src/lib/ai/coach/results/refs.ts | 14 ++
src/lib/ai/coach/tools/index.ts | 2 +-
.../turn/__tests__/dialog-frames.test.ts | 187 ++++++++++++++++++
src/lib/ai/coach/turn/addenda/clarify.ts | 12 ++
src/lib/ai/coach/turn/addenda/followups.ts | 10 +
src/lib/ai/coach/turn/addenda/index.ts | 23 +++
src/lib/ai/coach/turn/addenda/method.ts | 11 ++
src/lib/ai/coach/turn/addenda/results.ts | 11 ++
src/lib/ai/coach/turn/model.ts | 117 ++++++++++-
src/lib/ai/coach/turn/persist.ts | 6 +-
src/lib/ai/coach/turn/pipeline.ts | 46 ++++-
src/lib/ai/coach/turn/provenance.ts | 111 ++++++++++-
src/lib/ai/coach/turn/reply-guards.ts | 33 ++++
src/lib/ai/coach/turn/reuse-turn.ts | 20 ++
src/lib/ai/coach/turn/sse.ts | 22 ++-
src/lib/ai/coach/turn/steps.ts | 41 ++++
src/lib/ai/coach/turn/types.ts | 13 ++
tests/integration/backup-round-trip.test.ts | 5 +-
27 files changed, 863 insertions(+), 16 deletions(-)
create mode 100644 src/lib/ai/coach/clarify.ts
create mode 100644 src/lib/ai/coach/follow-ups/continue.ts
create mode 100644 src/lib/ai/coach/follow-ups/derive.ts
create mode 100644 src/lib/ai/coach/follow-ups/parse-sentinel.ts
create mode 100644 src/lib/ai/coach/follow-ups/resolve.ts
create mode 100644 src/lib/ai/coach/method.ts
create mode 100644 src/lib/ai/coach/results/chart-spec.ts
create mode 100644 src/lib/ai/coach/results/project.ts
create mode 100644 src/lib/ai/coach/results/refs.ts
create mode 100644 src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts
create mode 100644 src/lib/ai/coach/turn/addenda/clarify.ts
create mode 100644 src/lib/ai/coach/turn/addenda/followups.ts
create mode 100644 src/lib/ai/coach/turn/addenda/index.ts
create mode 100644 src/lib/ai/coach/turn/addenda/method.ts
create mode 100644 src/lib/ai/coach/turn/addenda/results.ts
create mode 100644 src/lib/ai/coach/turn/reuse-turn.ts
create mode 100644 src/lib/ai/coach/turn/steps.ts
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 985da05aa..e89e981c4 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -109,6 +109,8 @@ async function handleChatRequest(request: NextRequest): Promise {
scope,
guidedQuestion,
workoutId,
+ followUp,
+ clarification,
} = parsed.data;
// Per-user request-rate ceiling layered in front of the daily budget
@@ -171,6 +173,8 @@ async function handleChatRequest(request: NextRequest): Promise {
scope,
guidedQuestion,
workoutId,
+ followUp,
+ clarification,
recheckCapability: coachCapabilityRefusal,
});
}
diff --git a/src/lib/ai/coach/clarify.ts b/src/lib/ai/coach/clarify.ts
new file mode 100644
index 000000000..2a02ee4a3
--- /dev/null
+++ b/src/lib/ai/coach/clarify.ts
@@ -0,0 +1,34 @@
+/**
+ * Clarifying questions. The model may end a reply with a `---CLARIFY---`
+ * block when the metric or window stays genuinely ambiguous; the server
+ * validates the choices against what the record holds, and the question text
+ * is the reply itself. The person answers with
+ * `clarification: { messageId, choiceId? }` on the next request.
+ *
+ * Not built yet: the prose passes through unchanged, no clarification is
+ * offered, and an answer adds nothing to the turn.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import type { CoachClarification } from "@/lib/ai/coach/types";
+import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
+
+export function parseClarifySentinel(args: {
+ prose: string;
+ /** What the record holds; null on the no-tools path. */
+ inventory: InventoryEntry[] | null;
+ locale: Locale;
+}): { prose: string; clarification: CoachClarification | null } {
+ return { prose: args.prose, clarification: null };
+}
+
+/**
+ * The turn-context line for an answered clarification, or null when the
+ * request carries none or its question is no longer current.
+ */
+export async function resolveClarificationAnswer(_args: {
+ userId: string;
+ conversationId: string | undefined;
+ clarification: { messageId: string; choiceId?: string } | undefined;
+}): Promise {
+ return null;
+}
diff --git a/src/lib/ai/coach/follow-ups/continue.ts b/src/lib/ai/coach/follow-ups/continue.ts
new file mode 100644
index 000000000..34d90ebef
--- /dev/null
+++ b/src/lib/ai/coach/follow-ups/continue.ts
@@ -0,0 +1,15 @@
+/**
+ * "Keep looking": when the loop forced an answer at its round cap, offer a
+ * chip that continues the question with what was already fetched.
+ *
+ * Not built yet: no chip.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import type { CoachFollowUp } from "@/lib/ai/coach/types";
+
+export function buildContinueFollowUp(_args: {
+ forcedFinal: boolean;
+ locale: Locale;
+}): CoachFollowUp | null {
+ return null;
+}
diff --git a/src/lib/ai/coach/follow-ups/derive.ts b/src/lib/ai/coach/follow-ups/derive.ts
new file mode 100644
index 000000000..ae54daf34
--- /dev/null
+++ b/src/lib/ai/coach/follow-ups/derive.ts
@@ -0,0 +1,32 @@
+/**
+ * The follow-up chips under the latest assistant reply, derived on the
+ * server from what the turn read: as a chart, as a table, the period before,
+ * a year ago, a wider window, a related metric. At most three, deduplicated,
+ * labels from the catalog only.
+ *
+ * Pure. Not built yet: no chips.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import type {
+ CoachFollowUp,
+ CoachFollowUpKind,
+ CoachResultMeta,
+ CoachStep,
+ CoachStepDomain,
+} from "@/lib/ai/coach/types";
+import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
+import type { CoachPrefs } from "@/lib/validations/coach-prefs";
+
+export function deriveFollowUps(_args: {
+ results: CoachResultMeta[];
+ steps: CoachStep[];
+ /** What the record holds; null on the no-tools path. */
+ inventory: InventoryEntry[] | null;
+ /** Kind and domain pairs the model proposed, already catalog-checked. */
+ proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
+ forcedFinal: boolean;
+ prefs: CoachPrefs;
+ locale: Locale;
+}): CoachFollowUp[] {
+ return [];
+}
diff --git a/src/lib/ai/coach/follow-ups/parse-sentinel.ts b/src/lib/ai/coach/follow-ups/parse-sentinel.ts
new file mode 100644
index 000000000..5a1dd3df7
--- /dev/null
+++ b/src/lib/ai/coach/follow-ups/parse-sentinel.ts
@@ -0,0 +1,15 @@
+/**
+ * The optional `---FOLLOWUPS---` block in the reply: the model may propose
+ * a chip's kind and domain from the catalog, nothing else. The block is
+ * stripped from the prose; a malformed block is dropped silently.
+ *
+ * Not built yet: the prose passes through unchanged, with no proposals.
+ */
+import type { CoachFollowUpKind, CoachStepDomain } from "@/lib/ai/coach/types";
+
+export function parseFollowUpsSentinel(prose: string): {
+ prose: string;
+ proposals: Array<{ kind: CoachFollowUpKind; domain: CoachStepDomain }>;
+} {
+ return { prose, proposals: [] };
+}
diff --git a/src/lib/ai/coach/follow-ups/resolve.ts b/src/lib/ai/coach/follow-ups/resolve.ts
new file mode 100644
index 000000000..26787a854
--- /dev/null
+++ b/src/lib/ai/coach/follow-ups/resolve.ts
@@ -0,0 +1,26 @@
+/**
+ * Resolve a tapped follow-up chip (`followUp: { messageId, id }` on the
+ * request) against what the server persisted: the chip must sit on the
+ * conversation's latest assistant message. A reuse chip is answered from the
+ * stored table without a model call; any other becomes a turn-context hint.
+ * A chip that is no longer current degrades to a plain message.
+ *
+ * Not built yet: every request is a plain message.
+ */
+import type { CoachFollowUp } from "@/lib/ai/coach/types";
+
+/** A chip that resolved: what it asks for, and how it is answered. */
+export interface ResolvedFollowUp {
+ sourceMessageId: string;
+ followUp: CoachFollowUp;
+ /** A line for the turn context, on a chip answered by the model. */
+ contextHint: string | null;
+}
+
+export async function resolveFollowUp(_args: {
+ userId: string;
+ conversationId: string | undefined;
+ followUp: { messageId: string; id: string } | undefined;
+}): Promise {
+ return null;
+}
diff --git a/src/lib/ai/coach/method.ts b/src/lib/ai/coach/method.ts
new file mode 100644
index 000000000..fed6d0265
--- /dev/null
+++ b/src/lib/ai/coach/method.ts
@@ -0,0 +1,22 @@
+/**
+ * The method line under an answer: which sources, windows, counts and
+ * aggregation it rests on, rendered on the server in the request locale.
+ * "Blood pressure, last 90 days: 142 readings, weekly means · Sleep: no
+ * readings in this window". Never a health value.
+ *
+ * Not built yet: no method line.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import type {
+ CoachMethod,
+ CoachResultMeta,
+ CoachStep,
+} from "@/lib/ai/coach/types";
+
+export function buildMethod(_args: {
+ steps: CoachStep[];
+ results: CoachResultMeta[];
+ locale: Locale;
+}): CoachMethod | null {
+ return null;
+}
diff --git a/src/lib/ai/coach/results/chart-spec.ts b/src/lib/ai/coach/results/chart-spec.ts
new file mode 100644
index 000000000..aa3b6e67e
--- /dev/null
+++ b/src/lib/ai/coach/results/chart-spec.ts
@@ -0,0 +1,15 @@
+/**
+ * The chart a result table gets, chosen on the server from the table's shape
+ * and the metric: a line for level metrics over time, a bar for totals and
+ * category counts, a histogram for a distribution, or none (table only)
+ * when the table is too short, too long or has no numeric column.
+ *
+ * Pure. Not built yet: every table stays table-only.
+ */
+import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
+
+export function deriveChartSpec(
+ _table: Omit,
+): CoachChartSpec | null {
+ return null;
+}
diff --git a/src/lib/ai/coach/results/project.ts b/src/lib/ai/coach/results/project.ts
new file mode 100644
index 000000000..616b36223
--- /dev/null
+++ b/src/lib/ai/coach/results/project.ts
@@ -0,0 +1,32 @@
+/**
+ * Project this turn's tool results into result tables: per-sport counts for
+ * workouts, the latest reading per analyte for labs, per-night sleep, the
+ * compliance timeline, and the metric table tool's own rows.
+ *
+ * Input is only what the loop settled on THIS turn, so a table can never
+ * carry another turn's, or another account's, values.
+ *
+ * Not built yet: answers no tables.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import type { CoachResultTable } from "@/lib/ai/coach/types";
+import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
+
+/** One tool call of this turn, as the loop settled it. */
+export interface SettledToolCall {
+ name: string;
+ /** Schema-validated arguments; absent when they did not validate. */
+ args?: Record;
+ result: CoachToolResult;
+}
+
+/**
+ * Tables for this turn, refs `r1`.. in call order. `chart` and `chartKind`
+ * are left for `deriveChartSpec` to fill.
+ */
+export function projectResults(_args: {
+ calls: SettledToolCall[];
+ locale: Locale;
+}): CoachResultTable[] {
+ return [];
+}
diff --git a/src/lib/ai/coach/results/refs.ts b/src/lib/ai/coach/results/refs.ts
new file mode 100644
index 000000000..096b3767d
--- /dev/null
+++ b/src/lib/ai/coach/results/refs.ts
@@ -0,0 +1,14 @@
+/**
+ * `result:rN` references in the reply prose. The model marks the tables its
+ * answer relies on; the marks are stripped before the prose reaches the
+ * client, and a referenced table is shown expanded under the reply.
+ *
+ * Not built yet: the prose passes through unchanged and nothing counts as
+ * referenced.
+ */
+export function stripResultRefs(prose: string): {
+ prose: string;
+ referenced: string[];
+} {
+ return { prose, referenced: [] };
+}
diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts
index 6c3972b28..acee46e47 100644
--- a/src/lib/ai/coach/tools/index.ts
+++ b/src/lib/ai/coach/tools/index.ts
@@ -6,7 +6,7 @@
* figures, and a bounded loop that executes them. The legacy snapshot path
* stays alive as the no-tools fallback (per-provider `supportsTools`).
*/
-export { COACH_TOOL_DEFS } from "./definitions";
+export { COACH_TOOL_DEFS, parseCoachToolArgs } from "./definitions";
export type { CoachToolTrace } from "./executor";
export {
buildCoachDataInventory,
diff --git a/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts
new file mode 100644
index 000000000..bcd5f85ed
--- /dev/null
+++ b/src/lib/ai/coach/turn/__tests__/dialog-frames.test.ts
@@ -0,0 +1,187 @@
+/**
+ * v1.39.4 — the dialog frames go out in the documented order, after the
+ * provenance, and a blocked turn carries none of the tables, chips or
+ * clarification.
+ */
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({ prisma: {} }));
+vi.mock("@/lib/ai/coach/persistence", () => ({
+ appendMessage: vi.fn(),
+ createConversation: vi.fn(),
+}));
+
+import type {
+ CoachClarification,
+ CoachFollowUp,
+ CoachResultTable,
+ CoachStreamEvent,
+} from "@/lib/ai/coach/types";
+import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
+
+import { emitReply } from "../sse";
+import { assembleTurnDialog, buildTurnProvenance } from "../provenance";
+import type { ModelOutcome } from "../model";
+import type { GuardedReply } from "../reply-guards";
+
+const TABLE: CoachResultTable = {
+ ref: "r1",
+ source: {
+ tool: "get_metric_series",
+ domain: "bp",
+ window: "last30days",
+ period: "current",
+ },
+ shape: "timeSeries",
+ titleKey: "coach.result.title.metricByPeriod",
+ title: "Blood pressure by day",
+ rowCount: 1,
+ chartKind: null,
+ displayed: false,
+ columns: [],
+ rows: [["2026-09-01", 128]],
+ truncated: false,
+ chart: null,
+};
+const CHIP: CoachFollowUp = {
+ id: "f1",
+ kind: "as_table",
+ labelKey: "coach.followUp.asTable",
+ label: "Show as a table",
+ reuse: true,
+ origin: "server",
+};
+const CLARIFY: CoachClarification = {
+ kind: "window",
+ choices: [],
+ freeText: true,
+};
+
+function collect() {
+ const frames: CoachStreamEvent[] = [];
+ return {
+ frames,
+ emitter: {
+ emit: (f: CoachStreamEvent) => frames.push(f),
+ aborted: () => false,
+ },
+ };
+}
+
+describe("emitReply — dialog frames", () => {
+ it("orders token, provenance, result, cards, clarification, followUps, done", async () => {
+ const { frames, emitter } = collect();
+ await emitReply(
+ emitter,
+ {
+ ok: true,
+ replyText: "Steady.",
+ provenance: { windows: [], metrics: [] },
+ suggestion: { cadenceId: "c", measurementType: "BP", label: "l" },
+ action: null,
+ results: [TABLE],
+ followUps: [CHIP],
+ clarification: CLARIFY,
+ messageId: "m1",
+ totalTokens: 10,
+ model: "x",
+ },
+ "c1",
+ );
+ expect(frames.map((f) => f.type)).toEqual([
+ "token",
+ "provenance",
+ "result",
+ "suggestion",
+ "clarification",
+ "followUps",
+ "done",
+ ]);
+ });
+
+ it("emits no dialog frame when the turn has none", async () => {
+ const { frames, emitter } = collect();
+ await emitReply(
+ emitter,
+ {
+ ok: true,
+ replyText: "Steady.",
+ provenance: { windows: [], metrics: [] },
+ suggestion: null,
+ action: null,
+ results: [],
+ followUps: [],
+ clarification: null,
+ messageId: "m1",
+ totalTokens: 0,
+ model: null,
+ },
+ "c1",
+ );
+ expect(frames.map((f) => f.type)).toEqual(["token", "provenance", "done"]);
+ });
+});
+
+describe("assembleTurnDialog", () => {
+ const model = {
+ ok: true,
+ steps: [],
+ results: [TABLE],
+ forcedFinal: false,
+ inventory: null,
+ } as unknown as Extract;
+ const reply = (blocked: boolean): GuardedReply =>
+ ({
+ outboundBlocked: blocked,
+ referencedResults: ["r1"],
+ followUpProposals: [],
+ clarification: blocked ? null : CLARIFY,
+ keyValuesSentinel: {
+ keyValues: [],
+ malformed: false,
+ malformedEntries: [],
+ },
+ groundedFigures: [],
+ unverifiedStripped: 0,
+ }) as unknown as GuardedReply;
+
+ it("marks a referenced table displayed and keeps its values out of the provenance", () => {
+ const dialog = assembleTurnDialog({
+ model,
+ reply: reply(false),
+ prefs: DEFAULT_COACH_PREFS,
+ locale: "en",
+ });
+ expect(dialog.results).toEqual([{ ...TABLE, displayed: true }]);
+ const provenance = buildTurnProvenance({
+ snapshotProvenance: { windows: [], metrics: [] },
+ reply: reply(false),
+ suggestion: null,
+ action: null,
+ toolTrace: [],
+ steps: [],
+ dialog,
+ forcedFinal: true,
+ });
+ expect(provenance.results?.[0]).not.toHaveProperty("rows");
+ expect(provenance.results?.[0]?.displayed).toBe(true);
+ expect(provenance.clarification).toEqual(CLARIFY);
+ expect(provenance.forcedFinal).toBe(true);
+ expect(JSON.stringify(provenance)).not.toContain("128");
+ });
+
+ it("drops tables, chips and clarification on a blocked turn", () => {
+ const dialog = assembleTurnDialog({
+ model,
+ reply: reply(true),
+ prefs: DEFAULT_COACH_PREFS,
+ locale: "en",
+ });
+ expect(dialog).toEqual({
+ results: [],
+ method: null,
+ followUps: [],
+ clarification: null,
+ });
+ });
+});
diff --git a/src/lib/ai/coach/turn/addenda/clarify.ts b/src/lib/ai/coach/turn/addenda/clarify.ts
new file mode 100644
index 000000000..d5d37b574
--- /dev/null
+++ b/src/lib/ai/coach/turn/addenda/clarify.ts
@@ -0,0 +1,12 @@
+/**
+ * Prompt rules for clarifying questions: ask only when the metric or window
+ * stays genuinely ambiguous, at most one question, never about doses or
+ * diagnoses.
+ *
+ * Not written yet: empty.
+ */
+import type { Locale } from "@/lib/i18n/config";
+
+export function clarifyAddendum(_locale: Locale): string {
+ return "";
+}
diff --git a/src/lib/ai/coach/turn/addenda/followups.ts b/src/lib/ai/coach/turn/addenda/followups.ts
new file mode 100644
index 000000000..11167ff3b
--- /dev/null
+++ b/src/lib/ai/coach/turn/addenda/followups.ts
@@ -0,0 +1,10 @@
+/**
+ * Prompt rules for proposing follow-up chips from the catalog.
+ *
+ * Not written yet: empty.
+ */
+import type { Locale } from "@/lib/i18n/config";
+
+export function followUpsAddendum(_locale: Locale): string {
+ return "";
+}
diff --git a/src/lib/ai/coach/turn/addenda/index.ts b/src/lib/ai/coach/turn/addenda/index.ts
new file mode 100644
index 000000000..bb1983e54
--- /dev/null
+++ b/src/lib/ai/coach/turn/addenda/index.ts
@@ -0,0 +1,23 @@
+/**
+ * The dialog addenda appended to the tool-mode system prompt: result
+ * tables, clarification, the method line, follow-up chips. Each lives in its
+ * own file; an empty one adds nothing, so a turn with every addendum empty
+ * sends the prompt byte for byte as before.
+ */
+import type { Locale } from "@/lib/i18n/config";
+
+import { clarifyAddendum } from "./clarify";
+import { followUpsAddendum } from "./followups";
+import { methodAddendum } from "./method";
+import { resultsAddendum } from "./results";
+
+export function buildDialogAddenda(locale: Locale): string {
+ return [
+ resultsAddendum(locale),
+ clarifyAddendum(locale),
+ methodAddendum(locale),
+ followUpsAddendum(locale),
+ ]
+ .filter((block) => block.length > 0)
+ .join("\n\n");
+}
diff --git a/src/lib/ai/coach/turn/addenda/method.ts b/src/lib/ai/coach/turn/addenda/method.ts
new file mode 100644
index 000000000..49970dc9d
--- /dev/null
+++ b/src/lib/ai/coach/turn/addenda/method.ts
@@ -0,0 +1,11 @@
+/**
+ * Prompt rules for the method line and for rechecking a figure the person
+ * questions.
+ *
+ * Not written yet: empty.
+ */
+import type { Locale } from "@/lib/i18n/config";
+
+export function methodAddendum(_locale: Locale): string {
+ return "";
+}
diff --git a/src/lib/ai/coach/turn/addenda/results.ts b/src/lib/ai/coach/turn/addenda/results.ts
new file mode 100644
index 000000000..5a64721af
--- /dev/null
+++ b/src/lib/ai/coach/turn/addenda/results.ts
@@ -0,0 +1,11 @@
+/**
+ * Prompt rules for result tables: when to fetch a table, how to reference
+ * one, and when a stored table answers without a new fetch.
+ *
+ * Not written yet: empty.
+ */
+import type { Locale } from "@/lib/i18n/config";
+
+export function resultsAddendum(_locale: Locale): string {
+ return "";
+}
diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts
index a6d6ed35d..4c3296383 100644
--- a/src/lib/ai/coach/turn/model.ts
+++ b/src/lib/ai/coach/turn/model.ts
@@ -23,14 +23,24 @@ import {
renderDataInventory,
renderFocusHint,
buildToolModeAddendum,
+ parseCoachToolArgs,
runCoachToolLoop,
type CoachToolTrace,
} from "@/lib/ai/coach/tools";
+import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
+import type { CoachResultTable, CoachStep } from "@/lib/ai/coach/types";
+import {
+ projectResults,
+ type SettledToolCall,
+} from "@/lib/ai/coach/results/project";
+import { deriveChartSpec } from "@/lib/ai/coach/results/chart-spec";
+import { buildDialogAddenda } from "./addenda";
import { refundReservation, type TurnReservation } from "./budget";
import type { TurnChain } from "./chain";
import type { TurnContext } from "./context";
import { classifyBubblingProviderError } from "./errors";
+import { snapshotStep, toStep } from "./steps";
import type { TurnEmitter } from "./types";
export type ModelOutcome =
@@ -77,9 +87,56 @@ export type ModelOutcome =
* to the daily meter).
*/
cachedTokens: number;
+ /**
+ * v1.39.4 — the steps this turn read, in first-seen order: the tool
+ * calls on the tool path, one snapshot step on the no-tools path.
+ * Already streamed as `step` frames; persisted on the provenance.
+ */
+ steps: CoachStep[];
+ /**
+ * v1.39.4 — the tables this turn's tool results project to, with their
+ * charts. Not streamed here: they go out after the reply guards, and
+ * not at all on a blocked turn.
+ */
+ results: CoachResultTable[];
+ /** v1.39.4 — the loop forced the answer at its round cap. */
+ forcedFinal: boolean;
+ /** v1.39.4 — the DATA INVENTORY entries; null on the no-tools path. */
+ inventory: InventoryEntry[] | null;
}
| { ok: false; code: string };
+/**
+ * v1.39.4 — the turn's steps, upserted by id and streamed as they change.
+ * A frame is never written to a stream the client already closed.
+ */
+function stepRecorder(emitter: TurnEmitter): {
+ record(step: CoachStep | null): void;
+ list(): CoachStep[];
+} {
+ const byId = new Map();
+ return {
+ record(step) {
+ if (!step) return;
+ byId.set(step.id, step);
+ if (!emitter.aborted()) emitter.emit({ type: "step", step });
+ },
+ list: () => [...byId.values()],
+ };
+}
+
+/** v1.39.4 — a projected table with the chart the server chose for it. */
+function withChart(table: CoachResultTable): CoachResultTable {
+ const chart = deriveChartSpec(table);
+ return { ...table, chart, chartKind: chart?.kind ?? null };
+}
+
+/** v1.39.4 — server-authored lines appended to the system prompt. */
+function appendBlocks(base: string, blocks: string[]): string {
+ const extra = blocks.filter((block) => block.length > 0);
+ return extra.length > 0 ? [base, ...extra].join("\n\n") : base;
+}
+
export async function runTurnModel(args: {
userId: string;
locale: Locale;
@@ -89,14 +146,18 @@ export async function runTurnModel(args: {
chain: TurnChain;
toolMode: boolean;
reservation: TurnReservation;
- /**
- * The turn's frame channel. Not written to yet: the model step is where
- * live progress frames will originate.
- */
+ /** The turn's frame channel; the live `step` frames go out on it. */
emitter: TurnEmitter;
+ /**
+ * v1.39.4 — server-authored context lines for this turn (a resolved
+ * follow-up chip, an answered clarification). Empty on a plain turn,
+ * which then sends its prompt unchanged.
+ */
+ turnHints: string[];
}): Promise {
const { userId, locale, signal, conversationId, ctx, chain, toolMode } = args;
const { effectiveScope, workoutEvidence, turnContext, snapshot } = ctx;
+ const steps = stepRecorder(args.emitter);
try {
if (toolMode) {
// v1.20.0 (F1) — base context: the full system prompt + a tool-mode
@@ -108,7 +169,10 @@ export async function runTurnModel(args: {
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const toolRequest = buildCoachToolRequest({
systemPrompt: ctx.systemPrompt,
- toolModeAddendum: buildToolModeAddendum(locale),
+ toolModeAddendum: appendBlocks(buildToolModeAddendum(locale), [
+ buildDialogAddenda(locale),
+ ...args.turnHints,
+ ]),
focusHint: renderFocusHint(effectiveScope?.sources),
workoutEvidence,
dataInventory: renderDataInventory(inventory),
@@ -116,6 +180,9 @@ export async function runTurnModel(args: {
transcript: turnContext.transcript,
languageName: LANGUAGE_NAMES[locale],
});
+ // v1.39.4 — every call this turn settled, by its turn-wide index, for
+ // the result tables. Only this turn's own calls ever reach them.
+ const settled: SettledToolCall[] = [];
const loop = await runCoachToolLoop({
userId,
providers: chain,
@@ -135,6 +202,26 @@ export async function runTurnModel(args: {
signal,
// v1.22 (#89) — per-user response timeout for each tool-round call.
timeoutMs: ctx.aiResponseTimeoutMs,
+ // v1.39.4 — live steps: a `running` step as each call starts, its
+ // final status as it settles.
+ onCallStart: (call, index) =>
+ steps.record(
+ toStep({
+ call,
+ index,
+ parsedArgs: parseCoachToolArgs(call.name, call.arguments),
+ locale,
+ }),
+ ),
+ onCallSettled: (call, result, index) => {
+ const parsedArgs = parseCoachToolArgs(call.name, call.arguments);
+ settled[index] = {
+ name: call.name,
+ ...(parsedArgs ? { args: parsedArgs } : {}),
+ result,
+ };
+ steps.record(toStep({ call, index, parsedArgs, result, locale }));
+ },
});
// v1.32.1 — the numeric verifier ACTIVATES only when this turn actually
// delivered figures the model was told to ground against: a pinned
@@ -171,6 +258,13 @@ export async function runTurnModel(args: {
inventoryPayloads: [inventory.entries],
totalTokens: loop.totalTokens,
cachedTokens: loop.cachedTokens,
+ steps: steps.list(),
+ results: projectResults({
+ calls: settled.filter((call) => call !== undefined),
+ locale,
+ }).map(withChart),
+ forcedFinal: loop.forcedFinal === true,
+ inventory: inventory.entries,
};
}
// v1.22 (#89) — the no-tools path (local / Ollama / exo, and any chain
@@ -180,6 +274,13 @@ export async function runTurnModel(args: {
// heartbeat keeps the proxy connection warm and the assembled reply is
// returned in full so every guard still runs on the complete text.
let streamedDeltas = 0;
+ // v1.39.4 — the no-tools path reads the whole snapshot: one step.
+ steps.record(
+ snapshotStep({
+ metricCount: snapshot.provenance.metrics.length,
+ locale,
+ }),
+ );
const fallback = await runStreamingRawCompletionWithFallback({
surface: "coach",
userId,
@@ -192,7 +293,7 @@ export async function runTurnModel(args: {
// on every stateless no-tools request), so it ships as a single user
// message. The stable persona rides `system` and is cache-eligible.
params: singleUserTurn({
- system: ctx.systemPrompt,
+ system: appendBlocks(ctx.systemPrompt, args.turnHints),
user: ctx.userPrompt,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
@@ -228,6 +329,10 @@ export async function runTurnModel(args: {
inventoryPayloads: [],
totalTokens: result.tokensUsed ?? 0,
cachedTokens: result.cachedInputTokens ?? 0,
+ steps: steps.list(),
+ results: [],
+ forcedFinal: false,
+ inventory: null,
};
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
diff --git a/src/lib/ai/coach/turn/persist.ts b/src/lib/ai/coach/turn/persist.ts
index 516fcde44..3db6d66d9 100644
--- a/src/lib/ai/coach/turn/persist.ts
+++ b/src/lib/ai/coach/turn/persist.ts
@@ -4,7 +4,7 @@
import { annotate } from "@/lib/logging/context";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
-import type { CoachProvenance } from "@/lib/ai/coach/types";
+import type { CoachProvenance, CoachResultTable } from "@/lib/ai/coach/types";
import { appendMessage } from "@/lib/ai/coach/persistence";
import type { TurnContext } from "./context";
@@ -17,6 +17,8 @@ export async function persistAssistantReply(args: {
model: Extract;
ctx: TurnContext;
toolMode: boolean;
+ /** v1.39.4 — the tables to seal beside the message. */
+ results: CoachResultTable[];
}): Promise<{ messageId: string }> {
const { conversationId, replyText, provenance, model, ctx, toolMode } = args;
const workingProviderType: ProviderChainType = model.workingProviderType;
@@ -38,6 +40,8 @@ export async function persistAssistantReply(args: {
// reflects the true turn cost on the tool path too.
tokensUsed: totalTokensSpent || null,
model: model.result.model ?? null,
+ // v1.39.4 — the tables, encrypted into their own column.
+ ...(args.results.length > 0 ? { results: args.results } : {}),
});
// v1.18.7 — the day's spend was already reconciled against the
diff --git a/src/lib/ai/coach/turn/pipeline.ts b/src/lib/ai/coach/turn/pipeline.ts
index 7b109ac53..5a3b82f9c 100644
--- a/src/lib/ai/coach/turn/pipeline.ts
+++ b/src/lib/ai/coach/turn/pipeline.ts
@@ -19,10 +19,14 @@ import { resolveTurnChain } from "./chain";
import { assembleTurnContext } from "./context";
import { persistUserTurn, resolveTurnConversation } from "./conversation";
import { handleProducerFailure } from "./errors";
+import { resolveClarificationAnswer } from "@/lib/ai/coach/clarify";
+import { resolveFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
+
import { runTurnModel } from "./model";
import { persistAssistantReply } from "./persist";
-import { buildTurnProvenance } from "./provenance";
+import { assembleTurnDialog, buildTurnProvenance } from "./provenance";
import { guardReply } from "./reply-guards";
+import { runReuseTurn } from "./reuse-turn";
import {
SSE_HEADERS,
createTurnEmitter,
@@ -45,6 +49,32 @@ export async function runCoachTurn(input: TurnInput): Promise {
const conversation = resolved.conversation;
const workingConversationId = conversation.conversationId;
+ // v1.39.4 — a tapped chip or an answered question. Resolved against what
+ // the server persisted, never against what the client says the chip was.
+ // A reuse chip is answered from a stored table without a model call.
+ const resolvedFollowUp = await resolveFollowUp({
+ userId,
+ conversationId: input.conversationId,
+ followUp: input.followUp,
+ });
+ if (resolvedFollowUp?.followUp.reuse) {
+ const reused = await runReuseTurn({
+ input,
+ conversation,
+ resolved: resolvedFollowUp,
+ });
+ if (reused) return reused;
+ }
+ const clarifiedLine = await resolveClarificationAnswer({
+ userId,
+ conversationId: input.conversationId,
+ clarification: input.clarification,
+ });
+ const turnHints = [
+ ...(resolvedFollowUp?.contextHint ? [resolvedFollowUp.contextHint] : []),
+ ...(clarifiedLine ? [clarifiedLine] : []),
+ ];
+
await persistUserTurn(workingConversationId, message);
const ctx = await assembleTurnContext({
@@ -95,6 +125,7 @@ export async function runCoachTurn(input: TurnInput): Promise {
toolMode,
reservation,
emitter,
+ turnHints,
});
if (!model.ok) return model;
@@ -120,12 +151,21 @@ export async function runCoachTurn(input: TurnInput): Promise {
coachPrefs: ctx.coachPrefs,
reply,
});
+ const dialog = assembleTurnDialog({
+ model,
+ reply,
+ prefs: ctx.coachPrefs,
+ locale,
+ });
const provenance = buildTurnProvenance({
snapshotProvenance: ctx.snapshot.provenance,
reply,
suggestion: cards.suggestion,
action: cards.action,
toolTrace: model.toolTrace,
+ steps: model.steps,
+ dialog,
+ forcedFinal: model.forcedFinal,
});
const { messageId } = await persistAssistantReply({
conversationId: workingConversationId,
@@ -134,6 +174,7 @@ export async function runCoachTurn(input: TurnInput): Promise {
model,
ctx,
toolMode,
+ results: dialog.results,
});
return {
@@ -142,6 +183,9 @@ export async function runCoachTurn(input: TurnInput): Promise {
provenance,
suggestion: cards.suggestion,
action: cards.action,
+ results: dialog.results,
+ followUps: dialog.followUps,
+ clarification: dialog.clarification,
messageId,
totalTokens: model.totalTokens,
model: model.result.model ?? null,
diff --git a/src/lib/ai/coach/turn/provenance.ts b/src/lib/ai/coach/turn/provenance.ts
index 6000fb403..437d73982 100644
--- a/src/lib/ai/coach/turn/provenance.ts
+++ b/src/lib/ai/coach/turn/provenance.ts
@@ -1,24 +1,119 @@
/**
* The provenance envelope a Coach reply is persisted and streamed with:
* what the snapshot covered, plus what this turn added (key values, cards,
- * the tool trace, the grounded figures, the withheld-figure count).
+ * the tool trace, the grounded figures, the withheld-figure count, and the
+ * dialog: steps, tables, method, chips, clarification).
*/
import { annotate } from "@/lib/logging/context";
+import type { Locale } from "@/lib/i18n/config";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
-import type { CoachProvenance, CoachSuggestion } from "@/lib/ai/coach/types";
+import type {
+ CoachClarification,
+ CoachFollowUp,
+ CoachMethod,
+ CoachProvenance,
+ CoachResultMeta,
+ CoachResultTable,
+ CoachStep,
+ CoachSuggestion,
+} from "@/lib/ai/coach/types";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import type { CoachToolTrace } from "@/lib/ai/coach/tools";
+import { buildMethod } from "@/lib/ai/coach/method";
+import { deriveFollowUps } from "@/lib/ai/coach/follow-ups/derive";
+import { buildContinueFollowUp } from "@/lib/ai/coach/follow-ups/continue";
+import type { CoachPrefs } from "@/lib/validations/coach-prefs";
+import type { ModelOutcome } from "./model";
import type { GuardedReply } from "./reply-guards";
+/** v1.39.4 — at most this many chips under a reply. */
+const MAX_FOLLOW_UPS = 3;
+
+/** What the dialog adds to a guarded reply. */
+export interface TurnDialog {
+ /** The tables to stream and persist; empty on a blocked turn. */
+ results: CoachResultTable[];
+ method: CoachMethod | null;
+ /** Empty on a blocked turn. */
+ followUps: CoachFollowUp[];
+ /** Null on a blocked turn. */
+ clarification: CoachClarification | null;
+}
+
+/** A table's metadata: everything but the values, for the plaintext blob. */
+function toResultMeta(table: CoachResultTable): CoachResultMeta {
+ return {
+ ref: table.ref,
+ source: table.source,
+ shape: table.shape,
+ titleKey: table.titleKey,
+ title: table.title,
+ rowCount: table.rowCount,
+ chartKind: table.chartKind,
+ displayed: table.displayed,
+ ...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}),
+ };
+}
+
+/**
+ * v1.39.4 — the dialog of a guarded reply. On an outbound block the tables,
+ * chips and clarification are dropped, the way key values already are: the
+ * reply they belonged to was replaced.
+ */
+export function assembleTurnDialog(args: {
+ model: Extract;
+ reply: GuardedReply;
+ prefs: CoachPrefs;
+ locale: Locale;
+}): TurnDialog {
+ const { model, reply, prefs, locale } = args;
+ const blocked = reply.outboundBlocked;
+ const referenced = new Set(reply.referencedResults);
+ const results = blocked
+ ? []
+ : model.results.map((table) =>
+ referenced.has(table.ref) ? { ...table, displayed: true } : table,
+ );
+ const metas = results.map(toResultMeta);
+ const method = buildMethod({ steps: model.steps, results: metas, locale });
+ const continueChip = blocked
+ ? null
+ : buildContinueFollowUp({ forcedFinal: model.forcedFinal, locale });
+ const followUps = blocked
+ ? []
+ : [
+ ...(continueChip ? [continueChip] : []),
+ ...deriveFollowUps({
+ results: metas,
+ steps: model.steps,
+ inventory: model.inventory,
+ proposals: reply.followUpProposals,
+ forcedFinal: model.forcedFinal,
+ prefs,
+ locale,
+ }),
+ ].slice(0, MAX_FOLLOW_UPS);
+ return {
+ results,
+ method,
+ followUps,
+ clarification: reply.clarification,
+ };
+}
+
export function buildTurnProvenance(args: {
snapshotProvenance: CoachProvenance;
reply: GuardedReply;
suggestion: CoachSuggestion | null;
action: CoachSuggestedAction | null;
toolTrace: CoachToolTrace[];
+ /** v1.39.4 — the steps the turn read; kept on a blocked turn too. */
+ steps: CoachStep[];
+ dialog: TurnDialog;
+ forcedFinal: boolean;
}): CoachProvenance {
- const { snapshotProvenance, reply, toolTrace } = args;
+ const { snapshotProvenance, reply, toolTrace, steps, dialog } = args;
const surfacedSuggestion = args.suggestion;
const surfacedAction = args.action;
const sentinel = reply.keyValuesSentinel;
@@ -58,6 +153,16 @@ export function buildTurnProvenance(args: {
...(unverifiedStripped > 0
? { unverifiedFigures: unverifiedStripped }
: {}),
+ // v1.39.4 — the dialog. Metadata only: a table's values are in the
+ // encrypted column, never in this plaintext envelope.
+ ...(steps.length > 0 ? { steps } : {}),
+ ...(dialog.method ? { method: dialog.method } : {}),
+ ...(dialog.results.length > 0
+ ? { results: dialog.results.map(toResultMeta) }
+ : {}),
+ ...(dialog.followUps.length > 0 ? { followUps: dialog.followUps } : {}),
+ ...(dialog.clarification ? { clarification: dialog.clarification } : {}),
+ ...(args.forcedFinal ? { forcedFinal: true as const } : {}),
};
if (sentinel.malformed) {
// Graceful degrade: log so ops can spot a provider whose
diff --git a/src/lib/ai/coach/turn/reply-guards.ts b/src/lib/ai/coach/turn/reply-guards.ts
index 00be74862..ae7d9d9fd 100644
--- a/src/lib/ai/coach/turn/reply-guards.ts
+++ b/src/lib/ai/coach/turn/reply-guards.ts
@@ -29,6 +29,10 @@ import {
captureReminderFromSentinel,
} from "@/lib/ai/coach/reminders";
import { parseSuggestAction } from "@/lib/ai/coach/suggest-action";
+import { parseClarifySentinel } from "@/lib/ai/coach/clarify";
+import { parseFollowUpsSentinel } from "@/lib/ai/coach/follow-ups/parse-sentinel";
+import { stripResultRefs } from "@/lib/ai/coach/results/refs";
+import type { CoachClarification } from "@/lib/ai/coach/types";
import type { TurnContext } from "./context";
import type { ModelOutcome } from "./model";
@@ -43,6 +47,15 @@ export interface GuardedReply {
outboundBlocked: boolean;
groundedFigures: number[];
unverifiedStripped: number;
+ /** v1.39.4 — the refs (`r1`..) the prose marked as the tables it uses. */
+ referencedResults: string[];
+ /** v1.39.4 — the chip kinds and domains the model proposed. */
+ followUpProposals: ReturnType["proposals"];
+ /**
+ * v1.39.4 — the validated clarification, when this reply asks one. Null
+ * when it does not, and on a blocked turn (the question was replaced).
+ */
+ clarification: CoachClarification | null;
}
export async function guardReply(args: {
@@ -131,6 +144,21 @@ export async function guardReply(args: {
const actionParse = parseSuggestAction(replyText);
replyText = actionParse.prose.trim() || replyText;
+ // v1.39.4 — the dialog sentinels and marks, stripped before the safety
+ // screen and the number check read the prose: the optional
+ // `---FOLLOWUPS---` proposal, the optional `---CLARIFY---` block, and the
+ // `result:rN` marks (whose digits must never reach the number check).
+ const followUpsParse = parseFollowUpsSentinel(replyText);
+ replyText = followUpsParse.prose.trim() || replyText;
+ const clarifyParse = parseClarifySentinel({
+ prose: replyText,
+ inventory: model.inventory,
+ locale,
+ });
+ replyText = clarifyParse.prose.trim() || replyText;
+ const resultRefs = stripResultRefs(replyText);
+ replyText = resultRefs.prose.trim() || replyText;
+
// v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
// reply, before persistence and streaming. The inbound `detectRefusal`
// guards the user's message; this guards the model's reply for a
@@ -333,6 +361,11 @@ export async function guardReply(args: {
outboundBlocked: outbound.block,
groundedFigures,
unverifiedStripped,
+ referencedResults: resultRefs.referenced,
+ followUpProposals: followUpsParse.proposals,
+ // A blocked turn carries the fallback prose, so a question it asked is
+ // gone and its choices must not ride along.
+ clarification: outbound.block ? null : clarifyParse.clarification,
},
};
}
diff --git a/src/lib/ai/coach/turn/reuse-turn.ts b/src/lib/ai/coach/turn/reuse-turn.ts
new file mode 100644
index 000000000..66c14d23e
--- /dev/null
+++ b/src/lib/ai/coach/turn/reuse-turn.ts
@@ -0,0 +1,20 @@
+/**
+ * A model-free turn: a reuse chip ("as a chart", "as a table") answered from
+ * a table already stored on the conversation. No budget reservation and no
+ * provider call; the capability and the rate limit still apply at the route.
+ * Streams `step → token (caption) → provenance → result → followUps → done`
+ * and persists an assistant message with `providerType: "reuse"`.
+ *
+ * Not built yet: answers null, and the turn runs as a plain model turn.
+ */
+import type { ResolvedFollowUp } from "@/lib/ai/coach/follow-ups/resolve";
+
+import type { TurnConversation, TurnInput } from "./types";
+
+export async function runReuseTurn(_args: {
+ input: TurnInput;
+ conversation: TurnConversation;
+ resolved: ResolvedFollowUp;
+}): Promise {
+ return null;
+}
diff --git a/src/lib/ai/coach/turn/sse.ts b/src/lib/ai/coach/turn/sse.ts
index 9eb296148..d1a70394e 100644
--- a/src/lib/ai/coach/turn/sse.ts
+++ b/src/lib/ai/coach/turn/sse.ts
@@ -87,9 +87,10 @@ function flushTick(): Promise {
/**
* Emit a finished turn. A failed outcome is one `error` frame. A guarded
- * reply goes out as `token* → provenance → suggestion? → suggestedAction? →
- * done`; the token frames carry the FULLY-GUARDED text, and every guard ran
- * before the first one leaves.
+ * reply goes out as `token* → provenance → result* → suggestion? →
+ * suggestedAction? → clarification? → followUps? → done`; the token frames
+ * carry the FULLY-GUARDED text, and every guard ran before the first one
+ * leaves. The live `step` frames went out earlier, while the model ran.
*/
export async function emitReply(
emitter: TurnEmitter,
@@ -115,6 +116,11 @@ export async function emitReply(
}
if (emitter.aborted()) return;
emitter.emit({ type: "provenance", metricSource: outcome.provenance });
+ // v1.39.4 — additive `result` frames: the tables this turn read, after
+ // every guard and only on the owner's own stream.
+ for (const result of outcome.results) {
+ emitter.emit({ type: "result", result });
+ }
// v1.18.1 (Workstream C) — additive `suggestion` frame.
if (outcome.suggestion) {
emitter.emit({ type: "suggestion", suggestion: outcome.suggestion });
@@ -126,6 +132,16 @@ export async function emitReply(
suggestedAction: outcome.action,
});
}
+ // v1.39.4 — additive `clarification` and `followUps` frames.
+ if (outcome.clarification) {
+ emitter.emit({
+ type: "clarification",
+ clarification: outcome.clarification,
+ });
+ }
+ if (outcome.followUps.length > 0) {
+ emitter.emit({ type: "followUps", followUps: outcome.followUps });
+ }
// v1.18.9 — additive `usage` envelope on the `done` frame.
emitter.emit({
type: "done",
diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts
new file mode 100644
index 000000000..72cd3d381
--- /dev/null
+++ b/src/lib/ai/coach/turn/steps.ts
@@ -0,0 +1,41 @@
+/**
+ * Live steps: one `CoachStep` per tool call, emitted as `step` frames while
+ * the turn runs and persisted on `metricSource.steps`.
+ *
+ * A step carries a catalog label key, the server-rendered label, the domain,
+ * the window and a server-counted number. Never free text, an analyte name
+ * or a health value.
+ *
+ * Not built yet: both functions answer null, so a turn emits no step frame.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import type { AiToolCall } from "@/lib/ai/types";
+import type { CoachStep } from "@/lib/ai/coach/types";
+import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
+
+/**
+ * The step for one tool call: `running` when `result` is absent (the call
+ * just started), its final status once the result is in. `index` counts
+ * calls across the turn from 0; `parsedArgs` are the schema-validated
+ * arguments, absent when they did not validate.
+ */
+export function toStep(_args: {
+ call: AiToolCall;
+ index: number;
+ parsedArgs: Record | undefined;
+ result?: CoachToolResult;
+ locale: Locale;
+}): CoachStep | null {
+ return null;
+}
+
+/**
+ * The single step a no-tools turn shows: the full snapshot, with the number
+ * of metrics it covered.
+ */
+export function snapshotStep(_args: {
+ metricCount: number;
+ locale: Locale;
+}): CoachStep | null {
+ return null;
+}
diff --git a/src/lib/ai/coach/turn/types.ts b/src/lib/ai/coach/turn/types.ts
index c8a58b9dd..439c392fd 100644
--- a/src/lib/ai/coach/turn/types.ts
+++ b/src/lib/ai/coach/turn/types.ts
@@ -15,7 +15,10 @@ import type { Locale } from "@/lib/i18n/config";
import type { CoachTurn } from "@/lib/ai/coach/chat-request-builder";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import type {
+ CoachClarification,
+ CoachFollowUp,
CoachProvenance,
+ CoachResultTable,
CoachScope,
CoachStreamEvent,
CoachSuggestion,
@@ -32,6 +35,10 @@ export interface TurnInput {
scope: CoachScope | undefined;
guidedQuestion: string | undefined;
workoutId: string | undefined;
+ /** v1.39.4 — the follow-up chip this message came from, if any. */
+ followUp: { messageId: string; id: string } | undefined;
+ /** v1.39.4 — the clarifying question this message answers, if any. */
+ clarification: { messageId: string; choiceId?: string } | undefined;
/**
* The `coach` capability gate, re-run at the egress site. Owned by the
* route (the capability inventory reads it there); returns the refusal
@@ -80,6 +87,12 @@ export type ReplyOutcome =
provenance: CoachProvenance;
suggestion: CoachSuggestion | null;
action: CoachSuggestedAction | null;
+ /** v1.39.4 — tables streamed after the provenance, owner only. */
+ results: CoachResultTable[];
+ /** v1.39.4 — the chips under the reply. */
+ followUps: CoachFollowUp[];
+ /** v1.39.4 — the choices, when the reply is a question. */
+ clarification: CoachClarification | null;
messageId: string;
totalTokens: number;
model: string | null;
diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts
index 8f50d291b..046743cbb 100644
--- a/tests/integration/backup-round-trip.test.ts
+++ b/tests/integration/backup-round-trip.test.ts
@@ -2778,7 +2778,10 @@ describe("every model the plan claims two-ended survives a real restore", () =>
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose: "portable-export",
});
- const [turn] = payload.coachConversations[0].messages;
+ const { coachConversations } = payload as {
+ coachConversations: Array<{ messages: Array> }>;
+ };
+ const [turn] = coachConversations[0].messages;
expect(turn).toMatchObject({ resultsJson: COACH_RESULTS_JSON });
expect(turn).not.toHaveProperty("resultsEncrypted");
From ad72ae3e4cdbd42bcaa9d6152f0d72697100ed84 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 10:45:18 +0200
Subject: [PATCH 5/8] feat(coach): serve a message's stored tables and publish
the stream contract
GET /api/insights/chat/{id}/messages/{messageId}/results returns the
tables one assistant message read, narrowed to the conversation's owner
like the conversation read, with a table withheld as module_disabled when
its domain's module is off and as unavailable when it cannot be read. A
data read: no AI capability.
The published document now describes every stream frame as the
CoachStreamEvent union, including the suggestedAction frame it never
named, the new step, result, followUps and clarification frames, their
components, the provenance fields, the followUp and clarification request
fields, and the reuse provider type.
---
docs/api/openapi.yaml | 1381 +++++++++++++++--
src/app/api/__tests__/ai-route-inventory.ts | 2 +
.../results/__tests__/route.test.ts | 86 +
.../messages/[messageId]/results/route.ts | 53 +
src/lib/ai/coach/results/domain-module.ts | 40 +
src/lib/ai/coach/stream-events.ts | 2 +-
src/lib/openapi/routes/coach.ts | 126 +-
7 files changed, 1523 insertions(+), 167 deletions(-)
create mode 100644 src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts
create mode 100644 src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts
create mode 100644 src/lib/ai/coach/results/domain-module.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 824b25a94..00b530298 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -8879,18 +8879,27 @@ paths:
- Insights
summary: Send a Coach turn (streaming reply)
description: 'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is
- `text/event-stream`, not JSON: one `data: \n\n` frame per event. Frame `type` is one of `token` (a chunk
- of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion`
- (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{
- type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId,
- messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?,
- completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type,
- code, message, reason? }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the
- `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting
- `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an
- unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider,
- which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited.
- Auth via cookie or Bearer.'
+ `text/event-stream`, not JSON: one `data: \n\n` frame per event, each a `CoachStreamEvent` dispatched on
+ `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{
+ type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains,
+ windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence
+ envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`,
+ zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{
+ type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`;
+ nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification`
+ (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with
+ `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type,
+ followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional
+ reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and
+ `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{
+ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never
+ recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a
+ top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. The HTTP
+ status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status.
+ Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation.
+ Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the
+ capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none`
+ error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.'
requestBody:
required: true
content:
@@ -8899,12 +8908,12 @@ paths:
$ref: "#/components/schemas/CoachChatRequest"
responses:
"200":
- description: Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.
+ description: "Server-Sent Events stream: each `data: \\n\\n` frame is one `CoachStreamEvent`. Keepalive comment
+ lines (`: ka`) carry no data and are ignored."
content:
text/event-stream:
schema:
- type: string
- description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes."
+ $ref: "#/components/schemas/CoachStreamEvent"
"401": *a1
"403":
description: "The `coach` AI capability is unavailable for this record. `meta.capability` = `coach` and `meta.reason`
@@ -8931,6 +8940,47 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
+ /api/insights/chat/{id}/messages/{messageId}/results:
+ get:
+ tags:
+ - Insights
+ summary: Read the tables one Coach message read
+ description: "v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns
+ the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full
+ `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is
+ switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables
+ answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are
+ stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id
+ maps to 404 (never 403). Auth via cookie or Bearer."
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Conversation id.
+ - name: messageId
+ in: path
+ required: true
+ schema:
+ type: string
+ description: Assistant message id within the conversation.
+ responses:
+ "200":
+ description: The message's tables, each served whole or withheld.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachMessageResultsResponse"
+ "401": *a1
+ "404":
+ description: Conversation or message not found, or not owned by the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
/api/insights/chat/{id}:
get:
tags:
@@ -25598,13 +25648,44 @@ components:
workoutId:
type: string
maxLength: 64
+ followUp:
+ type: object
+ properties:
+ messageId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ id:
+ type: string
+ minLength: 1
+ maxLength: 8
+ required:
+ - messageId
+ - id
+ clarification:
+ type: object
+ properties:
+ messageId:
+ type: string
+ minLength: 1
+ maxLength: 64
+ choiceId:
+ type: string
+ minLength: 1
+ maxLength: 8
+ required:
+ - messageId
required:
- message
- description: Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a
+ description: "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a
new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows
which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server
defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers
- (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.
+ (client-side bubble, never persisted). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person
+ tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest
+ assistant message, and a chip that is no longer current is treated as a plain message. `clarification: {
+ messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer.
+ No `userId` field — the owner is narrowed from the session / Bearer."
RenameCoachConversationRequest:
type: object
properties:
@@ -37230,6 +37311,15 @@ components:
anyOf:
- type: string
- type: "null"
+ resultsEncrypted:
+ anyOf:
+ - type: string
+ minLength: 1
+ - type: "null"
+ resultsJson:
+ anyOf:
+ - type: string
+ - type: "null"
createdAt:
type: string
minLength: 1
@@ -49950,6 +50040,1181 @@ components:
- documentId
- title
additionalProperties: false
+ CoachStreamEvent:
+ oneOf:
+ - type: object
+ properties:
+ type:
+ type: string
+ const: token
+ token:
+ type: string
+ required:
+ - type
+ - token
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: provenance
+ metricSource:
+ $ref: "#/components/schemas/CoachProvenance"
+ required:
+ - type
+ - metricSource
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: suggestion
+ suggestion:
+ $ref: "#/components/schemas/CoachSuggestion"
+ required:
+ - type
+ - suggestion
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: suggestedAction
+ suggestedAction:
+ $ref: "#/components/schemas/CoachSuggestedActionCard"
+ required:
+ - type
+ - suggestedAction
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: reasoning
+ text:
+ type: string
+ required:
+ - type
+ - text
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: done
+ conversationId:
+ type: string
+ messageId:
+ type: string
+ usage:
+ $ref: "#/components/schemas/CoachUsage"
+ required:
+ - type
+ - conversationId
+ - messageId
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: error
+ code:
+ type: string
+ message:
+ type: string
+ reason:
+ type: string
+ enum:
+ - check_failed
+ - operator_disabled
+ - not_permitted_for_record
+ - module_disabled
+ - user_disabled
+ - no_provider
+ - consent_required
+ required:
+ - type
+ - code
+ - message
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: step
+ step:
+ $ref: "#/components/schemas/CoachStep"
+ required:
+ - type
+ - step
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: result
+ result:
+ $ref: "#/components/schemas/CoachResultTable"
+ required:
+ - type
+ - result
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: followUps
+ followUps:
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachFollowUp"
+ required:
+ - type
+ - followUps
+ additionalProperties: false
+ - type: object
+ properties:
+ type:
+ type: string
+ const: clarification
+ clarification:
+ $ref: "#/components/schemas/CoachClarification"
+ required:
+ - type
+ - clarification
+ additionalProperties: false
+ 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."
+ type: object
+ CoachProvenance:
+ type: object
+ properties:
+ windows:
+ readOnly: true
+ description: Analysis windows the assistant drew on this turn.
+ type: array
+ items:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ metrics:
+ readOnly: true
+ description: 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.
+ type: array
+ items:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ - general
+ counts:
+ description: Per-metric sample-count summary; absent on an empty snapshot.
+ type: object
+ propertyNames:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ - general
+ additionalProperties:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ keyValues:
+ description: Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8
+ entries.
+ readOnly: true
+ type: array
+ items:
+ type: object
+ properties:
+ label:
+ type: string
+ minLength: 1
+ maxLength: 80
+ value:
+ type: string
+ minLength: 1
+ maxLength: 40
+ unit:
+ type: string
+ maxLength: 16
+ window:
+ type: string
+ maxLength: 40
+ required:
+ - label
+ - value
+ additionalProperties: false
+ suggestion:
+ description: The cadence-suggestion card this turn carried, if any.
+ $ref: "#/components/schemas/CoachSuggestion"
+ suggestedAction:
+ description: The confirm-to-apply action card this turn carried, if any.
+ $ref: "#/components/schemas/CoachSuggestedActionCard"
+ toolCalls:
+ description: "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."
+ readOnly: true
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ present:
+ type: boolean
+ required:
+ - name
+ - present
+ additionalProperties: false
+ groundedFigures:
+ description: Bare magnitudes the turn's tools returned, recalled by a later turn's number check. Server-computed,
+ label-less.
+ readOnly: true
+ type: array
+ items:
+ type: number
+ unverifiedFigures:
+ description: 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.
+ type: integer
+ exclusiveMinimum: 0
+ maximum: 9007199254740991
+ steps:
+ description: v1.39.4 — what the Coach read on this turn.
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachStep"
+ method:
+ description: v1.39.4 — how the answer was reached.
+ $ref: "#/components/schemas/CoachMethod"
+ results:
+ description: v1.39.4 — the tables this turn produced, metadata only; the values are behind the results endpoint.
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachResultMeta"
+ followUps:
+ description: v1.39.4 — the chips offered under this reply.
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachFollowUp"
+ clarification:
+ description: v1.39.4 — the choices, when this reply is a question.
+ $ref: "#/components/schemas/CoachClarification"
+ forcedFinal:
+ description: v1.39.4 — the answer was forced at the round cap while the Coach still wanted to read. Absent otherwise.
+ type: boolean
+ const: true
+ required:
+ - windows
+ - metrics
+ additionalProperties: false
+ 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.
+ CoachSuggestion:
+ type: object
+ properties:
+ cadenceId:
+ type: string
+ measurementType:
+ type: string
+ label:
+ type: string
+ required:
+ - cadenceId
+ - measurementType
+ - label
+ additionalProperties: false
+ description: A cadence-suggestion card. Accepting it posts `cadenceId` to `POST /api/measurement-reminders`.
+ CoachSuggestedActionCard:
+ type: object
+ properties:
+ actionType:
+ type: string
+ enum:
+ - checkup.create
+ - reminder.note
+ summary:
+ type: string
+ titleKey:
+ type: string
+ params:
+ oneOf:
+ - type: object
+ properties:
+ actionType:
+ type: string
+ const: checkup.create
+ label:
+ type: string
+ interval:
+ type: string
+ required:
+ - actionType
+ - label
+ - interval
+ additionalProperties: false
+ - type: object
+ properties:
+ actionType:
+ type: string
+ const: reminder.note
+ note:
+ type: string
+ when:
+ type: string
+ metric:
+ type: string
+ required:
+ - actionType
+ - note
+ additionalProperties: false
+ type: object
+ required:
+ - actionType
+ - summary
+ - titleKey
+ - params
+ additionalProperties: false
+ 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`.
+ CoachStep:
+ type: object
+ properties:
+ id:
+ type: string
+ description: "`s1`..`s12`, unique within a turn."
+ tool:
+ type: string
+ enum:
+ - get_metric_series
+ - get_glucose_panel
+ - get_sleep
+ - get_medication_compliance
+ - get_labs
+ - get_illness_recovery
+ - get_workouts
+ - get_cycle
+ - get_correlations
+ - show_result
+ - snapshot
+ labelKey:
+ type: string
+ description: Closed catalog key (`coach.step.*`) the client localises.
+ label:
+ type: string
+ description: The label rendered on the server in the request locale. Render it when the client has no string for
+ `labelKey`.
+ domain:
+ $ref: "#/components/schemas/CoachStepDomain"
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ period:
+ type: string
+ enum:
+ - current
+ - previous
+ - yearAgo
+ granularity:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ status:
+ type: string
+ enum:
+ - running
+ - done
+ - empty
+ - failed
+ count:
+ description: Readings or rows the server counted. Never a health value.
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ reason:
+ type: string
+ enum:
+ - no_data
+ - outside_window
+ - module_disabled
+ - retrieval_failed
+ - invalid_arguments
+ resultRef:
+ description: The table this step produced (`r1`..), when it produced one.
+ type: string
+ required:
+ - id
+ - tool
+ - labelKey
+ - label
+ - status
+ additionalProperties: false
+ 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."
+ CoachStepDomain:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ - labs
+ - illness
+ - cycle
+ - correlations
+ - snapshot
+ 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)."
+ CoachMethod:
+ type: object
+ properties:
+ entries:
+ type: array
+ items:
+ type: object
+ properties:
+ domain:
+ $ref: "#/components/schemas/CoachStepDomain"
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ period:
+ type: string
+ enum:
+ - current
+ - previous
+ - yearAgo
+ granularity:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ count:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ aggregation:
+ type: string
+ enum:
+ - mean
+ - median
+ - latest
+ - sum
+ - count
+ - rate
+ absent:
+ type: string
+ enum:
+ - no_data
+ - outside_window
+ - module_disabled
+ required:
+ - domain
+ additionalProperties: false
+ text:
+ type: string
+ description: Rendered on the server in the request locale.
+ required:
+ - entries
+ - text
+ additionalProperties: false
+ description: "How an answer was reached: which sources, windows, counts and aggregation. Never a health value."
+ CoachResultMeta:
+ type: object
+ properties:
+ ref:
+ type: string
+ description: "`r1`..`r6`, unique within a message."
+ source:
+ type: object
+ properties:
+ tool:
+ type: string
+ enum:
+ - get_metric_series
+ - get_glucose_panel
+ - get_sleep
+ - get_medication_compliance
+ - get_labs
+ - get_illness_recovery
+ - get_workouts
+ - get_cycle
+ - get_correlations
+ domain:
+ $ref: "#/components/schemas/CoachStepDomain"
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ period:
+ type: string
+ enum:
+ - current
+ - previous
+ - yearAgo
+ granularity:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ required:
+ - tool
+ - domain
+ - window
+ - period
+ additionalProperties: false
+ shape:
+ type: string
+ enum:
+ - timeSeries
+ - categoryCounts
+ - distribution
+ - single
+ titleKey:
+ type: string
+ title:
+ type: string
+ rowCount:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: The full row count, before any trim.
+ chartKind:
+ anyOf:
+ - type: string
+ enum:
+ - line
+ - bar
+ - histogram
+ - type: "null"
+ displayed:
+ type: boolean
+ description: True when the answer referenced the table (shown expanded); false when it sits under the data-used
+ disclosure.
+ reusedFrom:
+ description: Set when the table was copied from an earlier message.
+ type: object
+ properties:
+ messageId:
+ type: string
+ ref:
+ type: string
+ required:
+ - messageId
+ - ref
+ additionalProperties: false
+ required:
+ - ref
+ - source
+ - shape
+ - titleKey
+ - title
+ - rowCount
+ - chartKind
+ - displayed
+ additionalProperties: false
+ 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`."
+ CoachFollowUp:
+ type: object
+ properties:
+ id:
+ type: string
+ description: "`f1`..`f3`."
+ kind:
+ type: string
+ enum:
+ - widen_window
+ - previous_period
+ - year_ago
+ - as_chart
+ - as_table
+ - related_metric
+ - continue
+ labelKey:
+ type: string
+ label:
+ type: string
+ description: Rendered on the server from a catalog; never model text.
+ anchor:
+ type: object
+ properties:
+ ref:
+ type: string
+ domain:
+ $ref: "#/components/schemas/CoachStepDomain"
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ granularity:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ period:
+ type: string
+ enum:
+ - current
+ - previous
+ - yearAgo
+ required:
+ - ref
+ - domain
+ additionalProperties: false
+ reuse:
+ type: boolean
+ description: True when the chip is answered from a stored table without a model call.
+ origin:
+ type: string
+ enum:
+ - server
+ - model
+ required:
+ - id
+ - kind
+ - labelKey
+ - label
+ - reuse
+ - origin
+ additionalProperties: false
+ description: "A chip offered under the latest assistant reply. Send it back as `followUp: { messageId, id }` with the
+ chip's label as `message`."
+ CoachClarification:
+ type: object
+ properties:
+ kind:
+ type: string
+ enum:
+ - metric
+ - window
+ - context
+ choices:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ labelKey:
+ type: string
+ label:
+ type: string
+ value:
+ type: object
+ properties:
+ metric:
+ type: string
+ enum:
+ - bp
+ - weight
+ - pulse
+ - mood
+ - compliance
+ - hrv
+ - sleep
+ - resting_hr
+ - steps
+ - active_energy
+ - flights
+ - distance
+ - vo2_max
+ - body_temp
+ - walking_hr
+ - respiratory_rate
+ - spo2
+ - pulse_wave_velocity
+ - vascular_age
+ - body_fat
+ - fat_mass
+ - fat_free_mass
+ - muscle_mass
+ - lean_body_mass
+ - bone_mass
+ - total_body_water
+ - bmi
+ - visceral_fat
+ - glucose
+ - walking_steadiness
+ - walking_asymmetry
+ - walking_double_support
+ - walking_step_length
+ - walking_speed
+ - audio_env
+ - audio_headphone
+ - audio_event
+ - daylight
+ - skin_temp
+ - workouts
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ additionalProperties: false
+ required:
+ - id
+ - labelKey
+ - label
+ - value
+ additionalProperties: false
+ description: At most 4. Metric choices are always ones the record holds.
+ freeText:
+ type: boolean
+ description: Whether a typed answer is welcome.
+ required:
+ - kind
+ - choices
+ - freeText
+ additionalProperties: false
+ description: "The choices for a clarifying question. The question itself is the assistant message's text. Answer with
+ `clarification: { messageId, choiceId? }`."
+ CoachUsage:
+ type: object
+ properties:
+ totalTokens:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ promptTokens:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ completionTokens:
+ anyOf:
+ - type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ - type: "null"
+ model:
+ anyOf:
+ - type: string
+ - type: "null"
+ required:
+ - totalTokens
+ additionalProperties: false
+ description: "Per-turn token usage. Server-authoritative: clients display it, never recompute it."
+ CoachResultTable:
+ type: object
+ properties:
+ ref:
+ type: string
+ description: "`r1`..`r6`, unique within a message."
+ source:
+ type: object
+ properties:
+ tool:
+ type: string
+ enum:
+ - get_metric_series
+ - get_glucose_panel
+ - get_sleep
+ - get_medication_compliance
+ - get_labs
+ - get_illness_recovery
+ - get_workouts
+ - get_cycle
+ - get_correlations
+ domain:
+ $ref: "#/components/schemas/CoachStepDomain"
+ window:
+ type: string
+ enum:
+ - last7days
+ - last30days
+ - last90days
+ - lastYear
+ - allTime
+ period:
+ type: string
+ enum:
+ - current
+ - previous
+ - yearAgo
+ granularity:
+ type: string
+ enum:
+ - day
+ - week
+ - month
+ required:
+ - tool
+ - domain
+ - window
+ - period
+ additionalProperties: false
+ shape:
+ type: string
+ enum:
+ - timeSeries
+ - categoryCounts
+ - distribution
+ - single
+ titleKey:
+ type: string
+ title:
+ type: string
+ rowCount:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ description: The full row count, before any trim.
+ chartKind:
+ anyOf:
+ - type: string
+ enum:
+ - line
+ - bar
+ - histogram
+ - type: "null"
+ displayed:
+ type: boolean
+ description: True when the answer referenced the table (shown expanded); false when it sits under the data-used
+ disclosure.
+ reusedFrom:
+ description: Set when the table was copied from an earlier message.
+ type: object
+ properties:
+ messageId:
+ type: string
+ ref:
+ type: string
+ required:
+ - messageId
+ - ref
+ additionalProperties: false
+ columns:
+ type: array
+ items:
+ type: object
+ properties:
+ key:
+ type: string
+ kind:
+ type: string
+ enum:
+ - period
+ - category
+ - number
+ - count
+ labelKey:
+ type: string
+ label:
+ type: string
+ unit:
+ description: Display unit token such as `mmHg`, never free text.
+ type: string
+ decimals:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - key
+ - kind
+ - labelKey
+ - label
+ additionalProperties: false
+ rows:
+ type: array
+ items:
+ type: array
+ items:
+ anyOf:
+ - type: string
+ - type: number
+ - type: "null"
+ description: Row-major cells, at most 400 rows. `null` is a period with no reading.
+ truncated:
+ type: boolean
+ description: True when `rowCount` exceeds the rows.
+ chart:
+ anyOf:
+ - oneOf:
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: line
+ x:
+ type: string
+ series:
+ type: array
+ items:
+ type: string
+ required:
+ - kind
+ - x
+ - series
+ additionalProperties: false
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: bar
+ x:
+ type: string
+ series:
+ type: array
+ items:
+ type: string
+ orientation:
+ type: string
+ enum:
+ - vertical
+ - horizontal
+ required:
+ - kind
+ - x
+ - series
+ - orientation
+ additionalProperties: false
+ - type: object
+ properties:
+ kind:
+ type: string
+ const: histogram
+ column:
+ type: string
+ unit:
+ type: string
+ bins:
+ type: array
+ items:
+ type: object
+ properties:
+ from:
+ type: number
+ to:
+ type: number
+ count:
+ type: integer
+ minimum: -9007199254740991
+ maximum: 9007199254740991
+ required:
+ - from
+ - to
+ - count
+ additionalProperties: false
+ required:
+ - kind
+ - column
+ - bins
+ additionalProperties: false
+ type: object
+ - type: "null"
+ description: The chart the server chose for the table, or null.
+ required:
+ - ref
+ - source
+ - shape
+ - titleKey
+ - title
+ - rowCount
+ - chartKind
+ - displayed
+ - columns
+ - rows
+ - truncated
+ - chart
+ additionalProperties: false
+ description: "A table of the values a turn read. Health data: encrypted at rest and sent only to the account that owns
+ the conversation."
+ CoachMessageResultsResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ results:
+ type: array
+ items:
+ $ref: "#/components/schemas/CoachResultEntry"
+ required:
+ - results
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ CoachResultEntry:
+ anyOf:
+ - $ref: "#/components/schemas/CoachResultTable"
+ - type: object
+ properties:
+ ref:
+ type: string
+ withheld:
+ type: string
+ enum:
+ - module_disabled
+ - unavailable
+ description: "`module_disabled`: the table's domain is switched off for this record. `unavailable`: the stored tables
+ could not be read."
+ required:
+ - ref
+ - withheld
+ additionalProperties: false
+ description: "One stored table, or the reason it is not served. Absence is explicit: a withheld table names why."
CoachConversationDetailResponse:
type: object
properties:
@@ -50053,9 +51318,11 @@ components:
anyOf:
- type: string
- type: "null"
- description: Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks
+ description: 'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks
an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should
- render it as an interrupted turn with a retry affordance. Null for user turns.
+ render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn
+ answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no
+ model call: render it as a normal assistant turn without a token footer. Null for user turns.'
promptVersion:
anyOf:
- type: string
@@ -50087,82 +51354,6 @@ components:
- model
additionalProperties: false
description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.
- CoachProvenance:
- type: object
- properties:
- windows:
- type: array
- items:
- type: string
- enum:
- - last7days
- - last30days
- - last90days
- - lastYear
- - allTime
- description: Analysis windows the assistant drew on this turn.
- metrics:
- type: array
- items:
- type: string
- description: 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:
- description: Per-metric sample-count summary; absent on an empty snapshot.
- type: object
- propertyNames:
- type: string
- additionalProperties:
- type: integer
- minimum: -9007199254740991
- maximum: 9007199254740991
- keyValues:
- description: Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8
- entries.
- type: array
- items:
- type: object
- properties:
- label:
- type: string
- value:
- type: string
- unit:
- type: string
- window:
- type: string
- required:
- - label
- - value
- additionalProperties: false
- toolCalls:
- description: "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."
- type: array
- items:
- type: object
- properties:
- name:
- type: string
- present:
- type: boolean
- required:
- - name
- - present
- additionalProperties: false
- unverifiedFigures:
- description: 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.
- type: integer
- exclusiveMinimum: 0
- maximum: 9007199254740991
- required:
- - windows
- - metrics
- additionalProperties: false
- description: Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence
- key-values. No raw timestamps.
CoachConversationRenamed:
type: object
properties:
diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts
index 7e90462ed..617556132 100644
--- a/src/app/api/__tests__/ai-route-inventory.ts
+++ b/src/app/api/__tests__/ai-route-inventory.ts
@@ -217,6 +217,8 @@ export const DATA_ROUTES: Readonly> = {
"One stored conversation: read, rename, delete.",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts":
"A rating on a message that already exists.",
+ "src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts":
+ "The stored tables of one message: the person's record, withheld per module, never per AI capability.",
"src/app/api/insights/breathing-screening/route.ts":
"Device breathing data, owned by the sleep module.",
"src/app/api/insights/labs-changes/route.ts":
diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts
new file mode 100644
index 000000000..b6bac0a18
--- /dev/null
+++ b/src/app/api/insights/chat/[id]/messages/[messageId]/results/__tests__/route.test.ts
@@ -0,0 +1,86 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NextRequest } from "next/server";
+import type * as ApiHandlerModule from "@/lib/api-handler";
+
+const { requireAuth, readMessageResults, resolveModuleMap } = vi.hoisted(
+ () => ({
+ requireAuth: vi.fn(),
+ readMessageResults: vi.fn(),
+ resolveModuleMap: vi.fn(),
+ }),
+);
+
+vi.mock("@/lib/api-handler", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ apiHandler: unknown>(handler: T) =>
+ handler,
+ requireAuth,
+ };
+});
+vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
+vi.mock("@/lib/modules/gate", () => ({ resolveModuleMap }));
+vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageResults }));
+
+import { GET } from "../route";
+
+const USER_ID = "user-1";
+
+function call(id = "c1", messageId = "m1") {
+ return GET(
+ new NextRequest(
+ `http://localhost/api/insights/chat/${id}/messages/${messageId}/results`,
+ ),
+ { params: Promise.resolve({ id, messageId }) },
+ ) as Promise;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ requireAuth.mockResolvedValue({ user: { id: USER_ID } });
+ resolveModuleMap.mockResolvedValue({ sleep: false, mood: true });
+});
+
+describe("GET /api/insights/chat/[id]/messages/[messageId]/results", () => {
+ it("reads under the caller's own id and the path ids only", async () => {
+ readMessageResults.mockResolvedValue([]);
+ const response = await call("c1", "m1");
+ expect(response.status).toBe(200);
+ expect(readMessageResults).toHaveBeenCalledWith(
+ USER_ID,
+ "c1",
+ "m1",
+ expect.any(Function),
+ );
+ expect(await response.json()).toEqual({
+ data: { results: [] },
+ error: null,
+ });
+ });
+
+ it("answers 404 for a message the caller does not own", async () => {
+ readMessageResults.mockResolvedValue(null);
+ const response = await call();
+ expect(response.status).toBe(404);
+ });
+
+ it("withholds a domain whose module is switched off, and only that one", async () => {
+ readMessageResults.mockResolvedValue([]);
+ await call();
+ const withhold = readMessageResults.mock.calls[0][3] as (
+ domain: string,
+ ) => boolean;
+ expect(withhold("sleep")).toBe(true);
+ expect(withhold("mood")).toBe(false);
+ // No module owns blood pressure or labs: never withheld.
+ expect(withhold("bp")).toBe(false);
+ expect(withhold("labs")).toBe(false);
+ });
+
+ it("refuses an unauthenticated caller before any read", async () => {
+ requireAuth.mockRejectedValue(new Error("Not authenticated"));
+ await expect(call()).rejects.toThrow("Not authenticated");
+ expect(readMessageResults).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts
new file mode 100644
index 000000000..29d6e1214
--- /dev/null
+++ b/src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts
@@ -0,0 +1,53 @@
+/**
+ * GET /api/insights/chat/[id]/messages/[messageId]/results — the tables of
+ * values one assistant message read, decrypted for their owner.
+ *
+ * A data read, never AI-gated: the tables are the person's stored record,
+ * readable while the Coach is unavailable for any reason, like the
+ * conversation they belong to. Narrowed exactly like
+ * `GET /api/insights/chat/[id]`: the message must sit in a conversation the
+ * caller owns, and a foreign or unknown id is 404, never 403.
+ *
+ * Each table is served whole or withheld with a reason: `module_disabled`
+ * when its domain's module is switched off for the record now (the table is
+ * not even decrypted), `unavailable` when the stored tables cannot be read.
+ * The list follows the order of `metricSource.results` on the message.
+ */
+import type { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiError, apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { resolveModuleMap } from "@/lib/modules/gate";
+import { readMessageResults } from "@/lib/ai/coach/persistence";
+import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
+
+interface RouteCtx {
+ params: Promise<{ id: string; messageId: string }>;
+}
+
+export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
+ const auth = await requireAuth();
+ const userId = auth.user.id;
+ const { id, messageId } = await ctx.params;
+ if (!id || !messageId) return apiError("coach.message.notFound", 404);
+
+ const modules = await resolveModuleMap(userId);
+ const results = await readMessageResults(userId, id, messageId, (domain) =>
+ isCoachDomainWithheld(domain, modules),
+ );
+ if (!results) return apiError("coach.message.notFound", 404);
+
+ annotate({
+ action: { name: "insights.coach.results.read" },
+ meta: {
+ conversationId: id,
+ tables: results.length,
+ withheld: results.filter((entry) => "withheld" in entry).length,
+ },
+ });
+
+ return apiSuccess({ results });
+});
+
+export const dynamic = "force-dynamic";
diff --git a/src/lib/ai/coach/results/domain-module.ts b/src/lib/ai/coach/results/domain-module.ts
new file mode 100644
index 000000000..624259e07
--- /dev/null
+++ b/src/lib/ai/coach/results/domain-module.ts
@@ -0,0 +1,40 @@
+/**
+ * Which module owns the domain a stored Coach table is about, so a table
+ * read after the module was switched off is withheld rather than served.
+ *
+ * The same ownership the Coach applies when it reads: the measurement-level
+ * table in `MODULE_SCOPED_SOURCES`, plus the cycle and illness domains,
+ * which the Coach gates on their own modules. Labs, medication compliance,
+ * correlations and the full snapshot answer to no module there, so none
+ * here either — gating them on this read would make a stored answer
+ * stricter than the Coach that wrote it.
+ */
+import { MODULE_SCOPED_SOURCES } from "@/lib/modules/measurement-scope";
+import type { ModuleKey } from "@/lib/modules/registry";
+import type { CoachStepDomain } from "@/lib/ai/coach/types";
+
+const OWNER_BY_DOMAIN: ReadonlyMap = (() => {
+ const out = new Map([
+ ["cycle", "cycle"],
+ ["illness", "illness"],
+ ]);
+ for (const [key, sources] of Object.entries(MODULE_SCOPED_SOURCES)) {
+ for (const source of sources ?? []) out.set(source, key as ModuleKey);
+ }
+ return out;
+})();
+
+export function moduleForCoachDomain(
+ domain: CoachStepDomain,
+): ModuleKey | null {
+ return OWNER_BY_DOMAIN.get(domain) ?? null;
+}
+
+/** True when the domain's module is switched off in `modules`. */
+export function isCoachDomainWithheld(
+ domain: CoachStepDomain,
+ modules: Readonly>,
+): boolean {
+ const owner = moduleForCoachDomain(domain);
+ return owner !== null && modules[owner] === false;
+}
diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts
index a6ddccff0..c30ce16d9 100644
--- a/src/lib/ai/coach/stream-events.ts
+++ b/src/lib/ai/coach/stream-events.ts
@@ -330,7 +330,7 @@ const coachSuggestedActionSchema = z
]),
})
.meta({
- id: "CoachSuggestedAction",
+ id: "CoachSuggestedActionCard",
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`.",
});
diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts
index 86fa35cea..4a0ca89ee 100644
--- a/src/lib/openapi/routes/coach.ts
+++ b/src/lib/openapi/routes/coach.ts
@@ -35,6 +35,11 @@ import {
coachPlanPatchSchema,
} from "@/lib/validations/coach-plan";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
+import {
+ coachProvenanceSchema,
+ coachResultEntrySchema,
+ coachStreamEventSchema,
+} from "@/lib/ai/coach/stream-events";
import {
coachAttachmentCreateSchema,
fencedChatRequestSchema,
@@ -468,68 +473,8 @@ const coachSuggestedActionResultResponse = z
// endpoint decrypts every message server-side, so the client never
// handles a key. The list endpoint stays metadata-only (no decryption).
-const coachProvenanceSchema = z
- .object({
- windows: z
- .array(
- z.enum([
- "last7days",
- "last30days",
- "last90days",
- "lastYear",
- "allTime",
- ]),
- )
- .describe("Analysis windows the assistant drew on this turn."),
- metrics: z
- .array(z.string())
- .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
- .record(z.string(), z.number().int())
- .optional()
- .describe(
- "Per-metric sample-count summary; absent on an empty snapshot.",
- ),
- keyValues: z
- .array(
- z.object({
- label: z.string(),
- value: z.string(),
- unit: z.string().optional(),
- window: z.string().optional(),
- }),
- )
- .optional()
- .describe(
- "Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
- ),
- toolCalls: z
- .array(
- z.object({
- name: z.string(),
- present: z.boolean(),
- }),
- )
- .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.",
- ),
- 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.",
- ),
- })
- .meta({
- id: "CoachProvenance",
- description:
- "Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.",
- });
+// The provenance envelope and the stream frames come from the Coach's own
+// wire mirror, which a type test holds equal to the TypeScript contract.
const coachMessageSchema = z
.object({
@@ -546,7 +491,7 @@ const coachMessageSchema = z
.string()
.nullable()
.describe(
- "Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. Null for user turns.",
+ 'Provider that produced the reply (e.g. anthropic, openai, local, refusal). The sentinel `cancelled` marks an empty assistant row closing a turn the client aborted mid-generation (navigation away); clients should render it as an interrupted turn with a retry affordance. v1.39.4 — the sentinel `reuse` marks a turn answered from a table already stored in the conversation (a follow-up chip such as "as a chart"), with no model call: render it as a normal assistant turn without a token footer. Null for user turns.',
),
promptVersion: z
.string()
@@ -720,7 +665,7 @@ const coachAttachmentsResponseSchema = z
const coachChatRequest = coachChatRequestSchema.meta({
id: "CoachChatRequest",
description:
- "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.",
+ "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). v1.39.4 — `followUp: { messageId, id }` names a follow-up chip the person tapped (send the chip's `label` as `message`); the server resolves the chip from the conversation's latest assistant message, and a chip that is no longer current is treated as a plain message. `clarification: { messageId, choiceId? }` answers a clarifying question, `choiceId` absent when the person typed their own answer. No `userId` field — the owner is narrowed from the session / Bearer.",
});
const anamnesisFactCurrentExistsResponse = {
@@ -790,7 +735,7 @@ export const coachPaths: NonNullable = {
tags: ["Insights"],
summary: "Send a Coach turn (streaming reply)",
description:
- 'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message, reason? }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.',
+ 'v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event, each a `CoachStreamEvent` dispatched on `type`. Frames, in the order they arrive: `step` (v1.39.4, live progress while the Coach reads the record: `{ type, step }`, upserted by `step.id` from `running` to `done` / `empty` / `failed`; catalog labels, domains, windows and counts only), `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `result` (v1.39.4, a table of the values the turn read: `{ type, result }`, zero or more, sent only to the account that owns the conversation), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `suggestedAction` (v1.22, a confirm-to-apply action card: `{ type, suggestedAction }`; nothing is created until the person confirms it through `POST /api/coach/suggested-actions`), `clarification` (v1.39.4, the choices when the reply is a clarifying question: `{ type, clarification }`; answer with `clarification` on the next request), `followUps` (v1.39.4, up to three chips under the reply: `{ type, followUps }`; send one back with `followUp` on the next request), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), and `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or a single `error` (`{ type, code, message, reason? }`). No frame added after v1.18.9 carries a top-level key an older frame uses, so a client decoding every frame into one flat struct is safe. The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Requires the `coach` AI capability, checked right after auth: an unavailable Coach is refused with the capability envelope before the stream opens, except a missing provider, which keeps its `coach.provider.none` error frame and adds `reason: "no_provider"`. Budget- and rate-limited. Auth via cookie or Bearer.',
requestBody: {
required: true,
content: {
@@ -800,14 +745,10 @@ export const coachPaths: NonNullable = {
responses: {
"200": {
description:
- "Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.",
+ "Server-Sent Events stream: each `data: \\n\\n` frame is one `CoachStreamEvent`. Keepalive comment lines (`: ka`) carry no data and are ignored.",
content: {
"text/event-stream": {
- schema: {
- type: "string",
- description:
- "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes.",
- },
+ schema: coachStreamEventSchema,
},
},
},
@@ -824,6 +765,49 @@ export const coachPaths: NonNullable = {
},
},
},
+ "/api/insights/chat/{id}/messages/{messageId}/results": {
+ get: {
+ tags: ["Insights"],
+ summary: "Read the tables one Coach message read",
+ description:
+ "v1.39.4 — the tables of values one assistant message read, decrypted server-side for the account that owns the conversation. One entry per table the message's `metricSource.results` lists, in that order: the full `CoachResultTable`, or `{ ref, withheld }` when it is not served — `module_disabled` when the table's domain is switched off for the record now, `unavailable` when the stored tables cannot be read. A message without tables answers an empty list. Fetch lazily, per message, when a table scrolls into view. Never AI-gated: the tables are stored data and stay readable while the Coach is unavailable. A foreign or unknown conversation or message id maps to 404 (never 403). Auth via cookie or Bearer.",
+ parameters: [
+ {
+ name: "id",
+ in: "path",
+ required: true,
+ schema: { type: "string" },
+ description: "Conversation id.",
+ },
+ {
+ name: "messageId",
+ in: "path",
+ required: true,
+ schema: { type: "string" },
+ description: "Assistant message id within the conversation.",
+ },
+ ],
+ responses: {
+ "200": {
+ description: "The message's tables, each served whole or withheld.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ z.object({ results: z.array(coachResultEntrySchema) }),
+ "CoachMessageResultsResponse",
+ ),
+ },
+ },
+ },
+ "404": {
+ description:
+ "Conversation or message not found, or not owned by the caller.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/insights/chat/{id}": {
get: {
tags: ["Insights"],
From fe0a03761e57e07cc1f7d7a12776b0439fe53c36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 10:50:49 +0200
Subject: [PATCH 6/8] feat(coach): message catalog for the dialog in all seven
languages
Adds every string the dialog needs up front: step labels, table titles,
columns and controls, the method line, the follow-up chips and their
setting, the clarification card, and the caption of an answer reused from
a stored table. A key catalog shared by server and client names each one;
a test holds the catalog and the bundles equal in both directions.
---
messages/de.json | 175 +++++++++++-
messages/en.json | 175 +++++++++++-
messages/es.json | 175 +++++++++++-
messages/fr.json | 175 +++++++++++-
messages/it.json | 175 +++++++++++-
messages/ko.json | 175 +++++++++++-
messages/pl.json | 175 +++++++++++-
.../ai/coach/__tests__/dialog-keys.test.ts | 157 +++++++++++
src/lib/ai/coach/dialog-keys.ts | 255 ++++++++++++++++++
9 files changed, 1630 insertions(+), 7 deletions(-)
create mode 100644 src/lib/ai/coach/__tests__/dialog-keys.test.ts
create mode 100644 src/lib/ai/coach/dialog-keys.ts
diff --git a/messages/de.json b/messages/de.json
index 5fea285f0..f37372452 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -9480,7 +9480,180 @@
"promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
"conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
},
- "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten."
+ "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten.",
+ "step": {
+ "read": "Lese {domain}",
+ "readWindow": "Lese {domain}, {window}",
+ "snapshot": "Lese die Übersicht deiner Daten",
+ "showResult": "Öffne eine frühere Tabelle",
+ "reuse": "Nutze die Tabelle von vorhin",
+ "headerActive": "{label}…",
+ "headerDoneOne": "1 Quelle angesehen",
+ "headerDoneFew": "{count} Quellen angesehen",
+ "headerDoneOther": "{count} Quellen angesehen",
+ "listLabel": "Was der Coach angesehen hat",
+ "toggleShow": "Anzeigen, was der Coach angesehen hat",
+ "toggleHide": "Ausblenden, was der Coach angesehen hat",
+ "announceDone": "Fertig: {label}",
+ "readingsOne": "1 Messwert",
+ "readingsFew": "{count} Messwerte",
+ "readingsOther": "{count} Messwerte",
+ "rowsOne": "1 Zeile",
+ "rowsFew": "{count} Zeilen",
+ "rowsOther": "{count} Zeilen",
+ "metricsOne": "1 Messgröße",
+ "metricsFew": "{count} Messgrößen",
+ "metricsOther": "{count} Messgrößen",
+ "reason": {
+ "noData": "keine Messwerte",
+ "outsideWindow": "keine Messwerte in diesem Zeitraum",
+ "moduleDisabled": "ausgeschaltet",
+ "retrievalFailed": "konnte nicht gelesen werden",
+ "invalidArguments": "Anfrage nicht verstanden"
+ },
+ "domain": {
+ "labs": "Laborwerte",
+ "illness": "Krankheit und Erholung",
+ "cycle": "Zyklus",
+ "correlations": "Zusammenhänge zwischen Messgrößen",
+ "snapshot": "Datenübersicht"
+ },
+ "window": {
+ "last7days": "letzte 7 Tage",
+ "last30days": "letzte 30 Tage",
+ "last90days": "letzte 90 Tage",
+ "lastYear": "letzte 12 Monate",
+ "allTime": "gesamter Zeitraum"
+ },
+ "period": {
+ "current": "dieser Zeitraum",
+ "previous": "der Zeitraum davor",
+ "yearAgo": "ein Jahr zuvor"
+ },
+ "granularity": {
+ "day": "nach Tag",
+ "week": "nach Woche",
+ "month": "nach Monat"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "{metric} nach Tag",
+ "byWeek": "{metric} nach Woche",
+ "byMonth": "{metric} nach Monat",
+ "previousPeriod": "{metric}, Zeitraum davor",
+ "yearAgo": "{metric}, ein Jahr zuvor",
+ "workoutsBySport": "Trainings nach Sportart",
+ "labsLatest": "Neueste Laborwerte",
+ "sleepByNight": "Schlaf nach Nacht",
+ "complianceByDay": "Medikamenten-Einnahmetreue nach Tag",
+ "distribution": "{metric}: wie oft welcher Bereich vorkam"
+ },
+ "column": {
+ "day": "Tag",
+ "week": "Woche",
+ "month": "Monat",
+ "night": "Nacht",
+ "sport": "Sportart",
+ "sessions": "Einheiten",
+ "analyte": "Laborwert",
+ "value": "Wert",
+ "unit": "Einheit",
+ "date": "Datum",
+ "referenceRange": "Referenzbereich",
+ "systolic": "Systolisch",
+ "diastolic": "Diastolisch",
+ "mean": "Durchschnitt",
+ "min": "Niedrigster",
+ "max": "Höchster",
+ "readings": "Messwerte",
+ "rate": "Einnahmetreue",
+ "duration": "Dauer",
+ "range": "Bereich",
+ "count": "Anzahl"
+ },
+ "other": "Sonstige",
+ "noReading": "kein Messwert",
+ "showAll": "Alle anzeigen ({count})",
+ "showFewer": "Weniger anzeigen",
+ "copyMenu": "Tabelle kopieren",
+ "copyForSpreadsheet": "Für Tabellenkalkulation kopieren",
+ "copyAsText": "Als Text kopieren",
+ "copied": "Kopiert",
+ "copyFailed": "Die Tabelle konnte nicht kopiert werden",
+ "viewLabel": "Ansicht",
+ "viewChart": "Diagramm",
+ "viewTable": "Tabelle",
+ "chartSummary": "{title} als Diagramm. Die Tabellenansicht listet jeden Wert auf.",
+ "dataUsed": "Verwendete Daten ({count})",
+ "truncated": "Die ersten {shown} von {total} Zeilen",
+ "withheld": {
+ "moduleDisabled": "Diese Tabelle ist ausgeblendet, weil ihr Modul ausgeschaltet ist.",
+ "unavailable": "Diese Tabelle konnte nicht geladen werden."
+ },
+ "loading": "Tabelle wird geladen…",
+ "loadFailed": "Die Tabellen konnten nicht geladen werden.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "Wie das ermittelt wurde",
+ "entry": "{domain}, {window}: {detail}",
+ "entryNoWindow": "{domain}: {detail}",
+ "readingsOne": "1 Messwert",
+ "readingsFew": "{count} Messwerte",
+ "readingsOther": "{count} Messwerte",
+ "aggregation": {
+ "mean": "Durchschnitt",
+ "median": "Median",
+ "latest": "letzter Messwert",
+ "sum": "Summe",
+ "count": "Anzahl",
+ "rate": "Quote"
+ },
+ "averages": {
+ "day": "Tagesdurchschnitte",
+ "week": "Wochendurchschnitte",
+ "month": "Monatsdurchschnitte"
+ },
+ "totals": {
+ "day": "Tagessummen",
+ "week": "Wochensummen",
+ "month": "Monatssummen"
+ },
+ "absent": {
+ "noData": "keine Messwerte",
+ "outsideWindow": "keine Messwerte in diesem Zeitraum",
+ "moduleDisabled": "ausgeschaltet"
+ }
+ },
+ "followUp": {
+ "groupLabel": "Vorgeschlagene Anschlussfragen",
+ "widenWindow": "Weiter zurückschauen",
+ "previousPeriod": "Mit dem Zeitraum davor vergleichen",
+ "yearAgo": "Mit dem Vorjahr vergleichen",
+ "asChart": "Als Diagramm zeigen",
+ "asTable": "Als Tabelle zeigen",
+ "relatedMetric": "Auch {metric} ansehen",
+ "continue": "Weitersuchen",
+ "settingLabel": "Anschlussfragen vorschlagen",
+ "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage."
+ },
+ "clarify": {
+ "cardLabel": "Der Coach hat eine Rückfrage",
+ "choicesLabel": "Mögliche Antworten",
+ "freeTextHint": "Du kannst auch selbst eine Antwort schreiben.",
+ "contextHint": "Schreib deine Antwort unten.",
+ "window": {
+ "last7days": "Letzte 7 Tage",
+ "last30days": "Letzte 30 Tage",
+ "last90days": "Letzte 90 Tage",
+ "lastYear": "Letzte 12 Monate",
+ "allTime": "Gesamter Zeitraum"
+ }
+ },
+ "reuse": {
+ "caption": "Hier sind dieselben Werte aus meiner letzten Antwort, unverändert."
+ }
},
"safety": {
"floor": {
diff --git a/messages/en.json b/messages/en.json
index ff67a71e9..2a914438f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -9480,7 +9480,180 @@
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
},
- "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers."
+ "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.",
+ "step": {
+ "read": "Reading {domain}",
+ "readWindow": "Reading {domain}, {window}",
+ "snapshot": "Reading your record summary",
+ "showResult": "Opening an earlier table",
+ "reuse": "Using the table from before",
+ "headerActive": "{label}…",
+ "headerDoneOne": "Looked at 1 source",
+ "headerDoneFew": "Looked at {count} sources",
+ "headerDoneOther": "Looked at {count} sources",
+ "listLabel": "What the Coach looked at",
+ "toggleShow": "Show what the Coach looked at",
+ "toggleHide": "Hide what the Coach looked at",
+ "announceDone": "Done: {label}",
+ "readingsOne": "1 reading",
+ "readingsFew": "{count} readings",
+ "readingsOther": "{count} readings",
+ "rowsOne": "1 row",
+ "rowsFew": "{count} rows",
+ "rowsOther": "{count} rows",
+ "metricsOne": "1 metric",
+ "metricsFew": "{count} metrics",
+ "metricsOther": "{count} metrics",
+ "reason": {
+ "noData": "no readings",
+ "outsideWindow": "no readings in this window",
+ "moduleDisabled": "switched off",
+ "retrievalFailed": "couldn't be read",
+ "invalidArguments": "request not understood"
+ },
+ "domain": {
+ "labs": "Lab results",
+ "illness": "Illness and recovery",
+ "cycle": "Cycle",
+ "correlations": "Connections between metrics",
+ "snapshot": "Record summary"
+ },
+ "window": {
+ "last7days": "last 7 days",
+ "last30days": "last 30 days",
+ "last90days": "last 90 days",
+ "lastYear": "last 12 months",
+ "allTime": "all time"
+ },
+ "period": {
+ "current": "this period",
+ "previous": "the period before",
+ "yearAgo": "a year earlier"
+ },
+ "granularity": {
+ "day": "by day",
+ "week": "by week",
+ "month": "by month"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "{metric} by day",
+ "byWeek": "{metric} by week",
+ "byMonth": "{metric} by month",
+ "previousPeriod": "{metric}, the period before",
+ "yearAgo": "{metric}, a year earlier",
+ "workoutsBySport": "Workouts by sport",
+ "labsLatest": "Latest lab results",
+ "sleepByNight": "Sleep by night",
+ "complianceByDay": "Medication adherence by day",
+ "distribution": "{metric}: how often each range came up"
+ },
+ "column": {
+ "day": "Day",
+ "week": "Week",
+ "month": "Month",
+ "night": "Night",
+ "sport": "Sport",
+ "sessions": "Sessions",
+ "analyte": "Test",
+ "value": "Value",
+ "unit": "Unit",
+ "date": "Date",
+ "referenceRange": "Reference range",
+ "systolic": "Systolic",
+ "diastolic": "Diastolic",
+ "mean": "Average",
+ "min": "Lowest",
+ "max": "Highest",
+ "readings": "Readings",
+ "rate": "Adherence",
+ "duration": "Duration",
+ "range": "Range",
+ "count": "Times"
+ },
+ "other": "Other",
+ "noReading": "no reading",
+ "showAll": "Show all ({count})",
+ "showFewer": "Show fewer",
+ "copyMenu": "Copy table",
+ "copyForSpreadsheet": "Copy for spreadsheet",
+ "copyAsText": "Copy as text",
+ "copied": "Copied",
+ "copyFailed": "Couldn't copy the table",
+ "viewLabel": "View",
+ "viewChart": "Chart",
+ "viewTable": "Table",
+ "chartSummary": "{title}, shown as a chart. The table view lists every value.",
+ "dataUsed": "Data used ({count})",
+ "truncated": "Showing the first {shown} of {total} rows",
+ "withheld": {
+ "moduleDisabled": "This table is hidden because its module is switched off.",
+ "unavailable": "This table couldn't be loaded."
+ },
+ "loading": "Loading table…",
+ "loadFailed": "Couldn't load the tables.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "How this was worked out",
+ "entry": "{domain}, {window}: {detail}",
+ "entryNoWindow": "{domain}: {detail}",
+ "readingsOne": "1 reading",
+ "readingsFew": "{count} readings",
+ "readingsOther": "{count} readings",
+ "aggregation": {
+ "mean": "average",
+ "median": "median",
+ "latest": "latest reading",
+ "sum": "total",
+ "count": "count",
+ "rate": "rate"
+ },
+ "averages": {
+ "day": "daily averages",
+ "week": "weekly averages",
+ "month": "monthly averages"
+ },
+ "totals": {
+ "day": "daily totals",
+ "week": "weekly totals",
+ "month": "monthly totals"
+ },
+ "absent": {
+ "noData": "no readings",
+ "outsideWindow": "no readings in this window",
+ "moduleDisabled": "switched off"
+ }
+ },
+ "followUp": {
+ "groupLabel": "Suggested follow-ups",
+ "widenWindow": "Look further back",
+ "previousPeriod": "Compare with the period before",
+ "yearAgo": "Compare with a year earlier",
+ "asChart": "Show as a chart",
+ "asTable": "Show as a table",
+ "relatedMetric": "Look at {metric} too",
+ "continue": "Keep looking",
+ "settingLabel": "Suggest follow-up questions",
+ "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer."
+ },
+ "clarify": {
+ "cardLabel": "The Coach has a question",
+ "choicesLabel": "Possible answers",
+ "freeTextHint": "You can also type your own answer.",
+ "contextHint": "Type your answer below.",
+ "window": {
+ "last7days": "Last 7 days",
+ "last30days": "Last 30 days",
+ "last90days": "Last 90 days",
+ "lastYear": "Last 12 months",
+ "allTime": "All time"
+ }
+ },
+ "reuse": {
+ "caption": "Here are the same figures from my last answer, unchanged."
+ }
},
"safety": {
"floor": {
diff --git a/messages/es.json b/messages/es.json
index 241ebd154..8c66d2e1d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -9480,7 +9480,180 @@
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
},
- "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos."
+ "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos.",
+ "step": {
+ "read": "Leyendo {domain}",
+ "readWindow": "Leyendo {domain}, {window}",
+ "snapshot": "Leyendo el resumen de tus datos",
+ "showResult": "Abriendo una tabla anterior",
+ "reuse": "Usando la tabla de antes",
+ "headerActive": "{label}…",
+ "headerDoneOne": "Consultó 1 fuente",
+ "headerDoneFew": "Consultó {count} fuentes",
+ "headerDoneOther": "Consultó {count} fuentes",
+ "listLabel": "Lo que consultó el Coach",
+ "toggleShow": "Mostrar lo que consultó el Coach",
+ "toggleHide": "Ocultar lo que consultó el Coach",
+ "announceDone": "Listo: {label}",
+ "readingsOne": "1 medición",
+ "readingsFew": "{count} mediciones",
+ "readingsOther": "{count} mediciones",
+ "rowsOne": "1 fila",
+ "rowsFew": "{count} filas",
+ "rowsOther": "{count} filas",
+ "metricsOne": "1 métrica",
+ "metricsFew": "{count} métricas",
+ "metricsOther": "{count} métricas",
+ "reason": {
+ "noData": "sin mediciones",
+ "outsideWindow": "sin mediciones en este periodo",
+ "moduleDisabled": "desactivado",
+ "retrievalFailed": "no se pudo leer",
+ "invalidArguments": "solicitud no entendida"
+ },
+ "domain": {
+ "labs": "Resultados de laboratorio",
+ "illness": "Enfermedad y recuperación",
+ "cycle": "Ciclo",
+ "correlations": "Relaciones entre métricas",
+ "snapshot": "Resumen de datos"
+ },
+ "window": {
+ "last7days": "últimos 7 días",
+ "last30days": "últimos 30 días",
+ "last90days": "últimos 90 días",
+ "lastYear": "últimos 12 meses",
+ "allTime": "todo el periodo"
+ },
+ "period": {
+ "current": "este periodo",
+ "previous": "el periodo anterior",
+ "yearAgo": "un año antes"
+ },
+ "granularity": {
+ "day": "por día",
+ "week": "por semana",
+ "month": "por mes"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "{metric} por día",
+ "byWeek": "{metric} por semana",
+ "byMonth": "{metric} por mes",
+ "previousPeriod": "{metric}, periodo anterior",
+ "yearAgo": "{metric}, un año antes",
+ "workoutsBySport": "Entrenamientos por deporte",
+ "labsLatest": "Resultados de laboratorio más recientes",
+ "sleepByNight": "Sueño por noche",
+ "complianceByDay": "Adherencia a la medicación por día",
+ "distribution": "{metric}: con qué frecuencia apareció cada rango"
+ },
+ "column": {
+ "day": "Día",
+ "week": "Semana",
+ "month": "Mes",
+ "night": "Noche",
+ "sport": "Deporte",
+ "sessions": "Sesiones",
+ "analyte": "Prueba",
+ "value": "Valor",
+ "unit": "Unidad",
+ "date": "Fecha",
+ "referenceRange": "Rango de referencia",
+ "systolic": "Sistólica",
+ "diastolic": "Diastólica",
+ "mean": "Promedio",
+ "min": "Mínimo",
+ "max": "Máximo",
+ "readings": "Mediciones",
+ "rate": "Adherencia",
+ "duration": "Duración",
+ "range": "Rango",
+ "count": "Veces"
+ },
+ "other": "Otros",
+ "noReading": "sin medición",
+ "showAll": "Mostrar todo ({count})",
+ "showFewer": "Mostrar menos",
+ "copyMenu": "Copiar tabla",
+ "copyForSpreadsheet": "Copiar para hoja de cálculo",
+ "copyAsText": "Copiar como texto",
+ "copied": "Copiado",
+ "copyFailed": "No se pudo copiar la tabla",
+ "viewLabel": "Vista",
+ "viewChart": "Gráfico",
+ "viewTable": "Tabla",
+ "chartSummary": "{title}, mostrado como gráfico. La vista de tabla recoge cada valor.",
+ "dataUsed": "Datos usados ({count})",
+ "truncated": "Se muestran las primeras {shown} de {total} filas",
+ "withheld": {
+ "moduleDisabled": "Esta tabla está oculta porque su módulo está desactivado.",
+ "unavailable": "No se pudo cargar esta tabla."
+ },
+ "loading": "Cargando tabla…",
+ "loadFailed": "No se pudieron cargar las tablas.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "Cómo se obtuvo",
+ "entry": "{domain}, {window}: {detail}",
+ "entryNoWindow": "{domain}: {detail}",
+ "readingsOne": "1 medición",
+ "readingsFew": "{count} mediciones",
+ "readingsOther": "{count} mediciones",
+ "aggregation": {
+ "mean": "promedio",
+ "median": "mediana",
+ "latest": "última medición",
+ "sum": "suma",
+ "count": "recuento",
+ "rate": "tasa"
+ },
+ "averages": {
+ "day": "promedios diarios",
+ "week": "promedios semanales",
+ "month": "promedios mensuales"
+ },
+ "totals": {
+ "day": "totales diarios",
+ "week": "totales semanales",
+ "month": "totales mensuales"
+ },
+ "absent": {
+ "noData": "sin mediciones",
+ "outsideWindow": "sin mediciones en este periodo",
+ "moduleDisabled": "desactivado"
+ }
+ },
+ "followUp": {
+ "groupLabel": "Preguntas sugeridas",
+ "widenWindow": "Mirar más atrás",
+ "previousPeriod": "Comparar con el periodo anterior",
+ "yearAgo": "Comparar con un año antes",
+ "asChart": "Mostrar como gráfico",
+ "asTable": "Mostrar como tabla",
+ "relatedMetric": "Ver también {metric}",
+ "continue": "Seguir buscando",
+ "settingLabel": "Sugerir preguntas de seguimiento",
+ "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach."
+ },
+ "clarify": {
+ "cardLabel": "El Coach tiene una pregunta",
+ "choicesLabel": "Respuestas posibles",
+ "freeTextHint": "También puedes escribir tu propia respuesta.",
+ "contextHint": "Escribe tu respuesta abajo.",
+ "window": {
+ "last7days": "Últimos 7 días",
+ "last30days": "Últimos 30 días",
+ "last90days": "Últimos 90 días",
+ "lastYear": "Últimos 12 meses",
+ "allTime": "Todo el periodo"
+ }
+ },
+ "reuse": {
+ "caption": "Aquí están las mismas cifras de mi última respuesta, sin cambios."
+ }
},
"safety": {
"floor": {
diff --git a/messages/fr.json b/messages/fr.json
index 64cbe69f1..6d333200b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -9480,7 +9480,180 @@
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
},
- "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs."
+ "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs.",
+ "step": {
+ "read": "Lecture : {domain}",
+ "readWindow": "Lecture : {domain}, {window}",
+ "snapshot": "Lecture du résumé de tes données",
+ "showResult": "Ouverture d'un tableau précédent",
+ "reuse": "Reprise du tableau précédent",
+ "headerActive": "{label}…",
+ "headerDoneOne": "1 source consultée",
+ "headerDoneFew": "{count} sources consultées",
+ "headerDoneOther": "{count} sources consultées",
+ "listLabel": "Ce que le Coach a consulté",
+ "toggleShow": "Afficher ce que le Coach a consulté",
+ "toggleHide": "Masquer ce que le Coach a consulté",
+ "announceDone": "Terminé : {label}",
+ "readingsOne": "1 mesure",
+ "readingsFew": "{count} mesures",
+ "readingsOther": "{count} mesures",
+ "rowsOne": "1 ligne",
+ "rowsFew": "{count} lignes",
+ "rowsOther": "{count} lignes",
+ "metricsOne": "1 indicateur",
+ "metricsFew": "{count} indicateurs",
+ "metricsOther": "{count} indicateurs",
+ "reason": {
+ "noData": "aucune mesure",
+ "outsideWindow": "aucune mesure sur cette période",
+ "moduleDisabled": "désactivé",
+ "retrievalFailed": "lecture impossible",
+ "invalidArguments": "demande non comprise"
+ },
+ "domain": {
+ "labs": "Résultats d'analyses",
+ "illness": "Maladie et récupération",
+ "cycle": "Cycle",
+ "correlations": "Liens entre indicateurs",
+ "snapshot": "Résumé des données"
+ },
+ "window": {
+ "last7days": "7 derniers jours",
+ "last30days": "30 derniers jours",
+ "last90days": "90 derniers jours",
+ "lastYear": "12 derniers mois",
+ "allTime": "toute la période"
+ },
+ "period": {
+ "current": "cette période",
+ "previous": "la période précédente",
+ "yearAgo": "un an plus tôt"
+ },
+ "granularity": {
+ "day": "par jour",
+ "week": "par semaine",
+ "month": "par mois"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "{metric} par jour",
+ "byWeek": "{metric} par semaine",
+ "byMonth": "{metric} par mois",
+ "previousPeriod": "{metric}, période précédente",
+ "yearAgo": "{metric}, un an plus tôt",
+ "workoutsBySport": "Entraînements par sport",
+ "labsLatest": "Derniers résultats d'analyses",
+ "sleepByNight": "Sommeil par nuit",
+ "complianceByDay": "Observance des médicaments par jour",
+ "distribution": "{metric} : fréquence de chaque plage"
+ },
+ "column": {
+ "day": "Jour",
+ "week": "Semaine",
+ "month": "Mois",
+ "night": "Nuit",
+ "sport": "Sport",
+ "sessions": "Séances",
+ "analyte": "Analyse",
+ "value": "Valeur",
+ "unit": "Unité",
+ "date": "Date",
+ "referenceRange": "Plage de référence",
+ "systolic": "Systolique",
+ "diastolic": "Diastolique",
+ "mean": "Moyenne",
+ "min": "Minimum",
+ "max": "Maximum",
+ "readings": "Mesures",
+ "rate": "Observance",
+ "duration": "Durée",
+ "range": "Plage",
+ "count": "Occurrences"
+ },
+ "other": "Autres",
+ "noReading": "aucune mesure",
+ "showAll": "Tout afficher ({count})",
+ "showFewer": "Afficher moins",
+ "copyMenu": "Copier le tableau",
+ "copyForSpreadsheet": "Copier pour un tableur",
+ "copyAsText": "Copier en texte",
+ "copied": "Copié",
+ "copyFailed": "Impossible de copier le tableau",
+ "viewLabel": "Affichage",
+ "viewChart": "Graphique",
+ "viewTable": "Tableau",
+ "chartSummary": "{title}, sous forme de graphique. La vue tableau liste chaque valeur.",
+ "dataUsed": "Données utilisées ({count})",
+ "truncated": "Affichage des {shown} premières lignes sur {total}",
+ "withheld": {
+ "moduleDisabled": "Ce tableau est masqué car son module est désactivé.",
+ "unavailable": "Impossible de charger ce tableau."
+ },
+ "loading": "Chargement du tableau…",
+ "loadFailed": "Impossible de charger les tableaux.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "Comment cela a été calculé",
+ "entry": "{domain}, {window} : {detail}",
+ "entryNoWindow": "{domain} : {detail}",
+ "readingsOne": "1 mesure",
+ "readingsFew": "{count} mesures",
+ "readingsOther": "{count} mesures",
+ "aggregation": {
+ "mean": "moyenne",
+ "median": "médiane",
+ "latest": "dernière mesure",
+ "sum": "somme",
+ "count": "nombre",
+ "rate": "taux"
+ },
+ "averages": {
+ "day": "moyennes journalières",
+ "week": "moyennes hebdomadaires",
+ "month": "moyennes mensuelles"
+ },
+ "totals": {
+ "day": "totaux journaliers",
+ "week": "totaux hebdomadaires",
+ "month": "totaux mensuels"
+ },
+ "absent": {
+ "noData": "aucune mesure",
+ "outsideWindow": "aucune mesure sur cette période",
+ "moduleDisabled": "désactivé"
+ }
+ },
+ "followUp": {
+ "groupLabel": "Questions suggérées",
+ "widenWindow": "Remonter plus loin",
+ "previousPeriod": "Comparer avec la période précédente",
+ "yearAgo": "Comparer avec l'an dernier",
+ "asChart": "Afficher en graphique",
+ "asTable": "Afficher en tableau",
+ "relatedMetric": "Regarder aussi {metric}",
+ "continue": "Continuer à chercher",
+ "settingLabel": "Suggérer des questions de suivi",
+ "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach."
+ },
+ "clarify": {
+ "cardLabel": "Le Coach a une question",
+ "choicesLabel": "Réponses possibles",
+ "freeTextHint": "Tu peux aussi écrire ta propre réponse.",
+ "contextHint": "Écris ta réponse ci-dessous.",
+ "window": {
+ "last7days": "7 derniers jours",
+ "last30days": "30 derniers jours",
+ "last90days": "90 derniers jours",
+ "lastYear": "12 derniers mois",
+ "allTime": "Toute la période"
+ }
+ },
+ "reuse": {
+ "caption": "Voici les mêmes chiffres que dans ma dernière réponse, sans changement."
+ }
},
"safety": {
"floor": {
diff --git a/messages/it.json b/messages/it.json
index 404c0ba2d..cbabb0406 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -9480,7 +9480,180 @@
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
},
- "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati."
+ "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati.",
+ "step": {
+ "read": "Lettura di {domain}",
+ "readWindow": "Lettura di {domain}, {window}",
+ "snapshot": "Lettura del riepilogo dei tuoi dati",
+ "showResult": "Apertura di una tabella precedente",
+ "reuse": "Uso della tabella di prima",
+ "headerActive": "{label}…",
+ "headerDoneOne": "1 fonte consultata",
+ "headerDoneFew": "{count} fonti consultate",
+ "headerDoneOther": "{count} fonti consultate",
+ "listLabel": "Cosa ha consultato il Coach",
+ "toggleShow": "Mostra cosa ha consultato il Coach",
+ "toggleHide": "Nascondi cosa ha consultato il Coach",
+ "announceDone": "Fatto: {label}",
+ "readingsOne": "1 misurazione",
+ "readingsFew": "{count} misurazioni",
+ "readingsOther": "{count} misurazioni",
+ "rowsOne": "1 riga",
+ "rowsFew": "{count} righe",
+ "rowsOther": "{count} righe",
+ "metricsOne": "1 parametro",
+ "metricsFew": "{count} parametri",
+ "metricsOther": "{count} parametri",
+ "reason": {
+ "noData": "nessuna misurazione",
+ "outsideWindow": "nessuna misurazione in questo periodo",
+ "moduleDisabled": "disattivato",
+ "retrievalFailed": "lettura non riuscita",
+ "invalidArguments": "richiesta non compresa"
+ },
+ "domain": {
+ "labs": "Esami di laboratorio",
+ "illness": "Malattia e recupero",
+ "cycle": "Ciclo",
+ "correlations": "Relazioni tra parametri",
+ "snapshot": "Riepilogo dei dati"
+ },
+ "window": {
+ "last7days": "ultimi 7 giorni",
+ "last30days": "ultimi 30 giorni",
+ "last90days": "ultimi 90 giorni",
+ "lastYear": "ultimi 12 mesi",
+ "allTime": "intero periodo"
+ },
+ "period": {
+ "current": "questo periodo",
+ "previous": "il periodo precedente",
+ "yearAgo": "un anno prima"
+ },
+ "granularity": {
+ "day": "per giorno",
+ "week": "per settimana",
+ "month": "per mese"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "{metric} per giorno",
+ "byWeek": "{metric} per settimana",
+ "byMonth": "{metric} per mese",
+ "previousPeriod": "{metric}, periodo precedente",
+ "yearAgo": "{metric}, un anno prima",
+ "workoutsBySport": "Allenamenti per sport",
+ "labsLatest": "Esami di laboratorio più recenti",
+ "sleepByNight": "Sonno per notte",
+ "complianceByDay": "Aderenza ai farmaci per giorno",
+ "distribution": "{metric}: quanto spesso è comparso ogni intervallo"
+ },
+ "column": {
+ "day": "Giorno",
+ "week": "Settimana",
+ "month": "Mese",
+ "night": "Notte",
+ "sport": "Sport",
+ "sessions": "Sessioni",
+ "analyte": "Esame",
+ "value": "Valore",
+ "unit": "Unità",
+ "date": "Data",
+ "referenceRange": "Intervallo di riferimento",
+ "systolic": "Sistolica",
+ "diastolic": "Diastolica",
+ "mean": "Media",
+ "min": "Minimo",
+ "max": "Massimo",
+ "readings": "Misurazioni",
+ "rate": "Aderenza",
+ "duration": "Durata",
+ "range": "Intervallo",
+ "count": "Volte"
+ },
+ "other": "Altro",
+ "noReading": "nessuna misurazione",
+ "showAll": "Mostra tutto ({count})",
+ "showFewer": "Mostra meno",
+ "copyMenu": "Copia tabella",
+ "copyForSpreadsheet": "Copia per foglio di calcolo",
+ "copyAsText": "Copia come testo",
+ "copied": "Copiato",
+ "copyFailed": "Impossibile copiare la tabella",
+ "viewLabel": "Vista",
+ "viewChart": "Grafico",
+ "viewTable": "Tabella",
+ "chartSummary": "{title}, mostrato come grafico. La vista tabella elenca ogni valore.",
+ "dataUsed": "Dati usati ({count})",
+ "truncated": "Prime {shown} righe su {total}",
+ "withheld": {
+ "moduleDisabled": "Questa tabella è nascosta perché il suo modulo è disattivato.",
+ "unavailable": "Impossibile caricare questa tabella."
+ },
+ "loading": "Caricamento della tabella…",
+ "loadFailed": "Impossibile caricare le tabelle.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "Come è stato ricavato",
+ "entry": "{domain}, {window}: {detail}",
+ "entryNoWindow": "{domain}: {detail}",
+ "readingsOne": "1 misurazione",
+ "readingsFew": "{count} misurazioni",
+ "readingsOther": "{count} misurazioni",
+ "aggregation": {
+ "mean": "media",
+ "median": "mediana",
+ "latest": "ultima misurazione",
+ "sum": "somma",
+ "count": "conteggio",
+ "rate": "tasso"
+ },
+ "averages": {
+ "day": "medie giornaliere",
+ "week": "medie settimanali",
+ "month": "medie mensili"
+ },
+ "totals": {
+ "day": "totali giornalieri",
+ "week": "totali settimanali",
+ "month": "totali mensili"
+ },
+ "absent": {
+ "noData": "nessuna misurazione",
+ "outsideWindow": "nessuna misurazione in questo periodo",
+ "moduleDisabled": "disattivato"
+ }
+ },
+ "followUp": {
+ "groupLabel": "Domande suggerite",
+ "widenWindow": "Guarda più indietro",
+ "previousPeriod": "Confronta con il periodo precedente",
+ "yearAgo": "Confronta con un anno prima",
+ "asChart": "Mostra come grafico",
+ "asTable": "Mostra come tabella",
+ "relatedMetric": "Guarda anche {metric}",
+ "continue": "Continua a cercare",
+ "settingLabel": "Suggerisci domande di approfondimento",
+ "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach."
+ },
+ "clarify": {
+ "cardLabel": "Il Coach ha una domanda",
+ "choicesLabel": "Risposte possibili",
+ "freeTextHint": "Puoi anche scrivere una tua risposta.",
+ "contextHint": "Scrivi la tua risposta qui sotto.",
+ "window": {
+ "last7days": "Ultimi 7 giorni",
+ "last30days": "Ultimi 30 giorni",
+ "last90days": "Ultimi 90 giorni",
+ "lastYear": "Ultimi 12 mesi",
+ "allTime": "Intero periodo"
+ }
+ },
+ "reuse": {
+ "caption": "Ecco gli stessi valori della mia ultima risposta, invariati."
+ }
},
"safety": {
"floor": {
diff --git a/messages/ko.json b/messages/ko.json
index 8ce36f817..2f031e7b3 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -9480,7 +9480,180 @@
"promptInjection": "제 지침을 무시하게 만들려는 표현이 보였어요. 저는 HealthLog 코치이고 본인의 건강 데이터만 정리해 드려요 — 그 범위에 맞게 다시 질문해 주세요.",
"conversationPoisoned": "이 대화의 앞선 메시지에 제 지침을 무시하게 만드는 표현이 있어요. 새 대화를 시작해 주세요."
},
- "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요."
+ "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요.",
+ "step": {
+ "read": "{domain} 확인 중",
+ "readWindow": "{domain}, {window} 확인 중",
+ "snapshot": "기록 요약 확인 중",
+ "showResult": "이전 표 여는 중",
+ "reuse": "앞서 만든 표 사용 중",
+ "headerActive": "{label}…",
+ "headerDoneOne": "출처 1개 확인함",
+ "headerDoneFew": "출처 {count}개 확인함",
+ "headerDoneOther": "출처 {count}개 확인함",
+ "listLabel": "코치가 확인한 내용",
+ "toggleShow": "코치가 확인한 내용 보기",
+ "toggleHide": "코치가 확인한 내용 숨기기",
+ "announceDone": "완료: {label}",
+ "readingsOne": "측정값 1개",
+ "readingsFew": "측정값 {count}개",
+ "readingsOther": "측정값 {count}개",
+ "rowsOne": "1행",
+ "rowsFew": "{count}행",
+ "rowsOther": "{count}행",
+ "metricsOne": "지표 1개",
+ "metricsFew": "지표 {count}개",
+ "metricsOther": "지표 {count}개",
+ "reason": {
+ "noData": "측정값 없음",
+ "outsideWindow": "이 기간에는 측정값 없음",
+ "moduleDisabled": "꺼져 있음",
+ "retrievalFailed": "읽지 못함",
+ "invalidArguments": "요청을 이해하지 못함"
+ },
+ "domain": {
+ "labs": "검사 결과",
+ "illness": "질병과 회복",
+ "cycle": "생리 주기",
+ "correlations": "지표 간 연관성",
+ "snapshot": "기록 요약"
+ },
+ "window": {
+ "last7days": "최근 7일",
+ "last30days": "최근 30일",
+ "last90days": "최근 90일",
+ "lastYear": "최근 12개월",
+ "allTime": "전체 기간"
+ },
+ "period": {
+ "current": "이번 기간",
+ "previous": "이전 기간",
+ "yearAgo": "1년 전"
+ },
+ "granularity": {
+ "day": "일별",
+ "week": "주별",
+ "month": "월별"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "일별 {metric}",
+ "byWeek": "주별 {metric}",
+ "byMonth": "월별 {metric}",
+ "previousPeriod": "{metric}, 이전 기간",
+ "yearAgo": "{metric}, 1년 전",
+ "workoutsBySport": "종목별 운동",
+ "labsLatest": "최근 검사 결과",
+ "sleepByNight": "밤별 수면",
+ "complianceByDay": "일별 복약 순응도",
+ "distribution": "{metric}: 구간별 빈도"
+ },
+ "column": {
+ "day": "날짜",
+ "week": "주",
+ "month": "월",
+ "night": "밤",
+ "sport": "종목",
+ "sessions": "횟수",
+ "analyte": "검사 항목",
+ "value": "값",
+ "unit": "단위",
+ "date": "일자",
+ "referenceRange": "참고 범위",
+ "systolic": "수축기",
+ "diastolic": "이완기",
+ "mean": "평균",
+ "min": "최저",
+ "max": "최고",
+ "readings": "측정 횟수",
+ "rate": "순응도",
+ "duration": "시간",
+ "range": "구간",
+ "count": "빈도"
+ },
+ "other": "기타",
+ "noReading": "측정값 없음",
+ "showAll": "모두 보기 ({count})",
+ "showFewer": "간단히 보기",
+ "copyMenu": "표 복사",
+ "copyForSpreadsheet": "스프레드시트용으로 복사",
+ "copyAsText": "텍스트로 복사",
+ "copied": "복사됨",
+ "copyFailed": "표를 복사하지 못했어요",
+ "viewLabel": "보기 방식",
+ "viewChart": "차트",
+ "viewTable": "표",
+ "chartSummary": "{title}을(를) 차트로 표시해요. 표 보기에서 모든 값을 확인할 수 있어요.",
+ "dataUsed": "사용한 데이터 ({count})",
+ "truncated": "전체 {total}행 중 처음 {shown}행 표시",
+ "withheld": {
+ "moduleDisabled": "해당 모듈이 꺼져 있어 이 표를 숨겼어요.",
+ "unavailable": "이 표를 불러오지 못했어요."
+ },
+ "loading": "표 불러오는 중…",
+ "loadFailed": "표를 불러오지 못했어요.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "산출 방법",
+ "entry": "{domain}, {window}: {detail}",
+ "entryNoWindow": "{domain}: {detail}",
+ "readingsOne": "측정값 1개",
+ "readingsFew": "측정값 {count}개",
+ "readingsOther": "측정값 {count}개",
+ "aggregation": {
+ "mean": "평균",
+ "median": "중앙값",
+ "latest": "최근 측정값",
+ "sum": "합계",
+ "count": "개수",
+ "rate": "비율"
+ },
+ "averages": {
+ "day": "일 평균",
+ "week": "주 평균",
+ "month": "월 평균"
+ },
+ "totals": {
+ "day": "일 합계",
+ "week": "주 합계",
+ "month": "월 합계"
+ },
+ "absent": {
+ "noData": "측정값 없음",
+ "outsideWindow": "이 기간에는 측정값 없음",
+ "moduleDisabled": "꺼져 있음"
+ }
+ },
+ "followUp": {
+ "groupLabel": "추천 후속 질문",
+ "widenWindow": "더 이전까지 보기",
+ "previousPeriod": "이전 기간과 비교",
+ "yearAgo": "1년 전과 비교",
+ "asChart": "차트로 보기",
+ "asTable": "표로 보기",
+ "relatedMetric": "{metric}도 보기",
+ "continue": "계속 찾아보기",
+ "settingLabel": "후속 질문 추천",
+ "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요."
+ },
+ "clarify": {
+ "cardLabel": "코치가 질문이 있어요",
+ "choicesLabel": "선택할 수 있는 답변",
+ "freeTextHint": "직접 답을 입력해도 돼요.",
+ "contextHint": "아래에 답을 입력해 주세요.",
+ "window": {
+ "last7days": "최근 7일",
+ "last30days": "최근 30일",
+ "last90days": "최근 90일",
+ "lastYear": "최근 12개월",
+ "allTime": "전체 기간"
+ }
+ },
+ "reuse": {
+ "caption": "지난 답변과 같은 수치를 그대로 보여드릴게요."
+ }
},
"safety": {
"floor": {
diff --git a/messages/pl.json b/messages/pl.json
index fccc01aca..775c3ed9d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -9480,7 +9480,180 @@
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
},
- "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach."
+ "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach.",
+ "step": {
+ "read": "Czytam: {domain}",
+ "readWindow": "Czytam: {domain}, {window}",
+ "snapshot": "Czytam podsumowanie Twoich danych",
+ "showResult": "Otwieram wcześniejszą tabelę",
+ "reuse": "Korzystam z tabeli z poprzedniej odpowiedzi",
+ "headerActive": "{label}…",
+ "headerDoneOne": "Sprawdzono 1 źródło",
+ "headerDoneFew": "Sprawdzono {count} źródła",
+ "headerDoneOther": "Sprawdzono {count} źródeł",
+ "listLabel": "Co sprawdził Coach",
+ "toggleShow": "Pokaż, co sprawdził Coach",
+ "toggleHide": "Ukryj, co sprawdził Coach",
+ "announceDone": "Gotowe: {label}",
+ "readingsOne": "1 pomiar",
+ "readingsFew": "{count} pomiary",
+ "readingsOther": "{count} pomiarów",
+ "rowsOne": "1 wiersz",
+ "rowsFew": "{count} wiersze",
+ "rowsOther": "{count} wierszy",
+ "metricsOne": "1 wskaźnik",
+ "metricsFew": "{count} wskaźniki",
+ "metricsOther": "{count} wskaźników",
+ "reason": {
+ "noData": "brak pomiarów",
+ "outsideWindow": "brak pomiarów w tym okresie",
+ "moduleDisabled": "wyłączone",
+ "retrievalFailed": "nie udało się odczytać",
+ "invalidArguments": "nie zrozumiano zapytania"
+ },
+ "domain": {
+ "labs": "Wyniki badań",
+ "illness": "Choroba i powrót do zdrowia",
+ "cycle": "Cykl",
+ "correlations": "Związki między wskaźnikami",
+ "snapshot": "Podsumowanie danych"
+ },
+ "window": {
+ "last7days": "ostatnie 7 dni",
+ "last30days": "ostatnie 30 dni",
+ "last90days": "ostatnie 90 dni",
+ "lastYear": "ostatnie 12 miesięcy",
+ "allTime": "cały okres"
+ },
+ "period": {
+ "current": "ten okres",
+ "previous": "poprzedni okres",
+ "yearAgo": "rok wcześniej"
+ },
+ "granularity": {
+ "day": "według dni",
+ "week": "według tygodni",
+ "month": "według miesięcy"
+ }
+ },
+ "result": {
+ "title": {
+ "byDay": "{metric} według dni",
+ "byWeek": "{metric} według tygodni",
+ "byMonth": "{metric} według miesięcy",
+ "previousPeriod": "{metric}, poprzedni okres",
+ "yearAgo": "{metric}, rok wcześniej",
+ "workoutsBySport": "Treningi według dyscypliny",
+ "labsLatest": "Najnowsze wyniki badań",
+ "sleepByNight": "Sen według nocy",
+ "complianceByDay": "Przyjmowanie leków według dni",
+ "distribution": "{metric}: jak często występował każdy zakres"
+ },
+ "column": {
+ "day": "Dzień",
+ "week": "Tydzień",
+ "month": "Miesiąc",
+ "night": "Noc",
+ "sport": "Dyscyplina",
+ "sessions": "Sesje",
+ "analyte": "Badanie",
+ "value": "Wartość",
+ "unit": "Jednostka",
+ "date": "Data",
+ "referenceRange": "Zakres referencyjny",
+ "systolic": "Skurczowe",
+ "diastolic": "Rozkurczowe",
+ "mean": "Średnia",
+ "min": "Najniższa",
+ "max": "Najwyższa",
+ "readings": "Pomiary",
+ "rate": "Regularność",
+ "duration": "Czas trwania",
+ "range": "Zakres",
+ "count": "Liczba razy"
+ },
+ "other": "Inne",
+ "noReading": "brak pomiaru",
+ "showAll": "Pokaż wszystko ({count})",
+ "showFewer": "Pokaż mniej",
+ "copyMenu": "Kopiuj tabelę",
+ "copyForSpreadsheet": "Kopiuj do arkusza kalkulacyjnego",
+ "copyAsText": "Kopiuj jako tekst",
+ "copied": "Skopiowano",
+ "copyFailed": "Nie udało się skopiować tabeli",
+ "viewLabel": "Widok",
+ "viewChart": "Wykres",
+ "viewTable": "Tabela",
+ "chartSummary": "{title} w formie wykresu. Widok tabeli pokazuje każdą wartość.",
+ "dataUsed": "Użyte dane ({count})",
+ "truncated": "Pierwsze {shown} z {total} wierszy",
+ "withheld": {
+ "moduleDisabled": "Ta tabela jest ukryta, bo jej moduł jest wyłączony.",
+ "unavailable": "Nie udało się wczytać tej tabeli."
+ },
+ "loading": "Wczytywanie tabeli…",
+ "loadFailed": "Nie udało się wczytać tabel.",
+ "histogramBin": "{from}–{to} {unit}"
+ },
+ "method": {
+ "label": "Jak to ustalono",
+ "entry": "{domain}, {window}: {detail}",
+ "entryNoWindow": "{domain}: {detail}",
+ "readingsOne": "1 pomiar",
+ "readingsFew": "{count} pomiary",
+ "readingsOther": "{count} pomiarów",
+ "aggregation": {
+ "mean": "średnia",
+ "median": "mediana",
+ "latest": "ostatni pomiar",
+ "sum": "suma",
+ "count": "liczba",
+ "rate": "odsetek"
+ },
+ "averages": {
+ "day": "średnie dzienne",
+ "week": "średnie tygodniowe",
+ "month": "średnie miesięczne"
+ },
+ "totals": {
+ "day": "sumy dzienne",
+ "week": "sumy tygodniowe",
+ "month": "sumy miesięczne"
+ },
+ "absent": {
+ "noData": "brak pomiarów",
+ "outsideWindow": "brak pomiarów w tym okresie",
+ "moduleDisabled": "wyłączone"
+ }
+ },
+ "followUp": {
+ "groupLabel": "Proponowane pytania",
+ "widenWindow": "Sięgnij dalej wstecz",
+ "previousPeriod": "Porównaj z poprzednim okresem",
+ "yearAgo": "Porównaj z tym samym okresem rok wcześniej",
+ "asChart": "Pokaż jako wykres",
+ "asTable": "Pokaż jako tabelę",
+ "relatedMetric": "Sprawdź też: {metric}",
+ "continue": "Szukaj dalej",
+ "settingLabel": "Proponuj kolejne pytania",
+ "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha."
+ },
+ "clarify": {
+ "cardLabel": "Coach ma pytanie",
+ "choicesLabel": "Możliwe odpowiedzi",
+ "freeTextHint": "Możesz też wpisać własną odpowiedź.",
+ "contextHint": "Wpisz odpowiedź poniżej.",
+ "window": {
+ "last7days": "Ostatnie 7 dni",
+ "last30days": "Ostatnie 30 dni",
+ "last90days": "Ostatnie 90 dni",
+ "lastYear": "Ostatnie 12 miesięcy",
+ "allTime": "Cały okres"
+ }
+ },
+ "reuse": {
+ "caption": "Oto te same wartości z mojej poprzedniej odpowiedzi, bez zmian."
+ }
},
"safety": {
"floor": {
diff --git a/src/lib/ai/coach/__tests__/dialog-keys.test.ts b/src/lib/ai/coach/__tests__/dialog-keys.test.ts
new file mode 100644
index 000000000..c445d0bd8
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/dialog-keys.test.ts
@@ -0,0 +1,157 @@
+/**
+ * The dialog's key catalog and the seven bundles agree, both ways.
+ *
+ * Forward: every key the catalog can produce — every label map entry, every
+ * domain, window, period and granularity, every plural tier — resolves in
+ * every locale. Reverse: every leaf under the namespaces the catalog owns
+ * (`coach.step`, `coach.result`, `coach.method`, `coach.followUp`,
+ * `coach.clarify`, `coach.reuse`) is one the catalog can produce, so a
+ * string cannot sit in the bundles with nothing able to reach it.
+ *
+ * Checked by breaking it: deleting `coach.result.copied` from `ko.json`
+ * fails the forward test by locale and key; adding an unused leaf under
+ * `coach.step` fails the reverse test by name.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import { locales, type Locale } from "@/lib/i18n/config";
+import {
+ coachScopeWindowSchema,
+ type CoachResultGranularity,
+ type CoachResultPeriod,
+} from "@/lib/ai/coach/types";
+import { coachStepDomainSchema } from "@/lib/ai/coach/stream-events";
+import {
+ COACH_CLARIFY_UI_KEYS,
+ COACH_FOLLOW_UP_KEYS,
+ COACH_FOLLOW_UP_UI_KEYS,
+ COACH_METHOD_ABSENT_KEYS,
+ COACH_METHOD_AGGREGATION_KEYS,
+ COACH_METHOD_KEYS,
+ COACH_RESULT_COLUMN_KEYS,
+ COACH_RESULT_TITLE_KEYS,
+ COACH_RESULT_UI_KEYS,
+ COACH_RESULT_WITHHELD_KEYS,
+ COACH_REUSE_CAPTION_KEY,
+ COACH_STEP_LABEL_KEYS,
+ COACH_STEP_REASON_KEYS,
+ COACH_STEP_UI_KEYS,
+ clarifyWindowLabelKey,
+ coachDomainLabelKey,
+ coachGranularityLabelKey,
+ coachPeriodLabelKey,
+ coachWindowLabelKey,
+ methodAveragesKey,
+ methodReadingsKey,
+ methodTotalsKey,
+ stepHeaderDoneKey,
+ stepMetricsKey,
+ stepReadingsKey,
+ stepRowsKey,
+} from "@/lib/ai/coach/dialog-keys";
+
+const ROOT = join(__dirname, "../../../../..");
+
+function bundle(locale: Locale): Record {
+ return JSON.parse(
+ readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"),
+ ) as Record;
+}
+
+function leaves(node: unknown, prefix: string, out: Map) {
+ if (node && typeof node === "object") {
+ for (const [key, value] of Object.entries(node)) {
+ leaves(value, prefix ? `${prefix}.${key}` : key, out);
+ }
+ } else {
+ out.set(prefix, node);
+ }
+ return out;
+}
+
+const PERIODS: CoachResultPeriod[] = ["current", "previous", "yearAgo"];
+const GRANULARITIES: CoachResultGranularity[] = ["day", "week", "month"];
+
+/** Counts that land on every plural tier some locale uses. */
+const COUNTS = [0, 1, 2, 3, 5, 12, 22];
+
+function catalogKeys(): Set {
+ const keys = new Set([
+ ...Object.values(COACH_STEP_LABEL_KEYS),
+ ...Object.values(COACH_STEP_UI_KEYS),
+ ...Object.values(COACH_STEP_REASON_KEYS),
+ ...Object.values(COACH_RESULT_TITLE_KEYS),
+ ...Object.values(COACH_RESULT_COLUMN_KEYS),
+ ...Object.values(COACH_RESULT_UI_KEYS),
+ ...Object.values(COACH_RESULT_WITHHELD_KEYS),
+ ...Object.values(COACH_METHOD_KEYS),
+ ...Object.values(COACH_METHOD_AGGREGATION_KEYS),
+ ...Object.values(COACH_METHOD_ABSENT_KEYS),
+ ...Object.values(COACH_FOLLOW_UP_KEYS),
+ ...Object.values(COACH_FOLLOW_UP_UI_KEYS),
+ ...Object.values(COACH_CLARIFY_UI_KEYS),
+ COACH_REUSE_CAPTION_KEY,
+ ]);
+ for (const domain of coachStepDomainSchema.options) {
+ keys.add(coachDomainLabelKey(domain));
+ }
+ for (const window of coachScopeWindowSchema.options) {
+ keys.add(coachWindowLabelKey(window));
+ keys.add(clarifyWindowLabelKey(window));
+ }
+ for (const period of PERIODS) {
+ keys.add(coachPeriodLabelKey(period));
+ }
+ for (const granularity of GRANULARITIES) {
+ keys.add(coachGranularityLabelKey(granularity));
+ keys.add(methodAveragesKey(granularity));
+ keys.add(methodTotalsKey(granularity));
+ }
+ for (const locale of locales) {
+ for (const count of COUNTS) {
+ keys.add(stepHeaderDoneKey(count, locale));
+ keys.add(stepReadingsKey(count, locale));
+ keys.add(stepRowsKey(count, locale));
+ keys.add(stepMetricsKey(count, locale));
+ keys.add(methodReadingsKey(count, locale));
+ }
+ }
+ return keys;
+}
+
+const OWNED = [
+ "coach.step",
+ "coach.result",
+ "coach.method",
+ "coach.followUp",
+ "coach.clarify",
+ "coach.reuse",
+];
+
+describe("Coach dialog keys", () => {
+ const keys = catalogKeys();
+
+ it("produces a non-trivial catalog", () => {
+ expect(keys.size).toBeGreaterThan(130);
+ });
+
+ it.each(locales)("every catalog key resolves in %s", (locale) => {
+ const strings = leaves(bundle(locale), "", new Map());
+ const missing = [...keys].filter(
+ (key) => typeof strings.get(key) !== "string" || strings.get(key) === "",
+ );
+ expect(missing).toEqual([]);
+ });
+
+ it("every leaf under the owned namespaces is reachable from the catalog", () => {
+ const strings = leaves(bundle("en"), "", new Map());
+ const owned = [...strings.keys()].filter((key) =>
+ OWNED.some((ns) => key.startsWith(`${ns}.`)),
+ );
+ expect(owned.length).toBeGreaterThan(0);
+ expect(owned.filter((key) => !keys.has(key))).toEqual([]);
+ });
+});
diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts
new file mode 100644
index 000000000..a04dec646
--- /dev/null
+++ b/src/lib/ai/coach/dialog-keys.ts
@@ -0,0 +1,255 @@
+/**
+ * The message keys of the Coach dialog: live steps, result tables, the
+ * method line, follow-up chips, clarifying questions, the reuse caption.
+ *
+ * One closed catalog, shared by the server (which renders every `label` in
+ * the request locale) and the web client (which renders `labelKey` when it
+ * has the string, and the server's `label` when it does not). A label never
+ * carries model text: it is one of these keys plus server-chosen values
+ * (a domain, a window, a count).
+ *
+ * Every key here exists in all seven bundles; `__tests__/dialog-keys.test.ts`
+ * holds that, in both directions for the namespaces this file owns.
+ *
+ * Client-safe: no server import.
+ */
+import type { Locale } from "@/lib/i18n/config";
+import { pluralKey } from "@/lib/i18n/plural";
+import {
+ coachScopeSourceSchema,
+ type CoachFollowUpKind,
+ type CoachMethodEntry,
+ type CoachResultGranularity,
+ type CoachResultPeriod,
+ type CoachScopeWindow,
+ type CoachStepDomain,
+ type CoachStepReason,
+} from "@/lib/ai/coach/types";
+
+// ── Steps ─────────────────────────────────────────────────────────────────
+
+/** The step label templates (`{domain}`, `{window}`). */
+export const COACH_STEP_LABEL_KEYS = {
+ read: "coach.step.read",
+ readWindow: "coach.step.readWindow",
+ snapshot: "coach.step.snapshot",
+ showResult: "coach.step.showResult",
+ reuse: "coach.step.reuse",
+} as const;
+
+/** The step list's own chrome. */
+export const COACH_STEP_UI_KEYS = {
+ headerActive: "coach.step.headerActive",
+ listLabel: "coach.step.listLabel",
+ toggleShow: "coach.step.toggleShow",
+ toggleHide: "coach.step.toggleHide",
+ announceDone: "coach.step.announceDone",
+} as const;
+
+/** "Looked at N sources", by plural tier. */
+export function stepHeaderDoneKey(count: number, locale: Locale): string {
+ return pluralKey("coach.step.headerDone", count, locale);
+}
+
+/** "N readings" on a step row, by plural tier. */
+export function stepReadingsKey(count: number, locale: Locale): string {
+ return pluralKey("coach.step.readings", count, locale);
+}
+
+/** "N rows" on a step row, by plural tier. */
+export function stepRowsKey(count: number, locale: Locale): string {
+ return pluralKey("coach.step.rows", count, locale);
+}
+
+/** "N metrics" on the snapshot step, by plural tier. */
+export function stepMetricsKey(count: number, locale: Locale): string {
+ return pluralKey("coach.step.metrics", count, locale);
+}
+
+export const COACH_STEP_REASON_KEYS: Readonly> =
+ {
+ no_data: "coach.step.reason.noData",
+ outside_window: "coach.step.reason.outsideWindow",
+ module_disabled: "coach.step.reason.moduleDisabled",
+ retrieval_failed: "coach.step.reason.retrievalFailed",
+ invalid_arguments: "coach.step.reason.invalidArguments",
+ };
+
+const SCOPE_SOURCES: ReadonlySet = new Set(
+ coachScopeSourceSchema.options,
+);
+
+/**
+ * A domain's display name. A measurement-backed scope source reuses the
+ * metric names the sources rail already shows; the domains read as a whole
+ * have their own.
+ */
+export function coachDomainLabelKey(domain: CoachStepDomain): string {
+ return SCOPE_SOURCES.has(domain)
+ ? `insights.coach.metric.${domain}`
+ : `coach.step.domain.${domain}`;
+}
+
+/** A window in running text ("last 30 days"). */
+export function coachWindowLabelKey(window: CoachScopeWindow): string {
+ return `coach.step.window.${window}`;
+}
+
+export function coachPeriodLabelKey(period: CoachResultPeriod): string {
+ return `coach.step.period.${period}`;
+}
+
+export function coachGranularityLabelKey(
+ granularity: CoachResultGranularity,
+): string {
+ return `coach.step.granularity.${granularity}`;
+}
+
+// ── Results ───────────────────────────────────────────────────────────────
+
+/** Table titles; `{metric}` where the title names one. */
+export const COACH_RESULT_TITLE_KEYS = {
+ byDay: "coach.result.title.byDay",
+ byWeek: "coach.result.title.byWeek",
+ byMonth: "coach.result.title.byMonth",
+ previousPeriod: "coach.result.title.previousPeriod",
+ yearAgo: "coach.result.title.yearAgo",
+ workoutsBySport: "coach.result.title.workoutsBySport",
+ labsLatest: "coach.result.title.labsLatest",
+ sleepByNight: "coach.result.title.sleepByNight",
+ complianceByDay: "coach.result.title.complianceByDay",
+ distribution: "coach.result.title.distribution",
+} as const;
+
+/** Column headings. */
+export const COACH_RESULT_COLUMN_KEYS = {
+ day: "coach.result.column.day",
+ week: "coach.result.column.week",
+ month: "coach.result.column.month",
+ night: "coach.result.column.night",
+ sport: "coach.result.column.sport",
+ sessions: "coach.result.column.sessions",
+ analyte: "coach.result.column.analyte",
+ value: "coach.result.column.value",
+ unit: "coach.result.column.unit",
+ date: "coach.result.column.date",
+ referenceRange: "coach.result.column.referenceRange",
+ systolic: "coach.result.column.systolic",
+ diastolic: "coach.result.column.diastolic",
+ mean: "coach.result.column.mean",
+ min: "coach.result.column.min",
+ max: "coach.result.column.max",
+ readings: "coach.result.column.readings",
+ rate: "coach.result.column.rate",
+ duration: "coach.result.column.duration",
+ range: "coach.result.column.range",
+ count: "coach.result.column.count",
+} as const;
+
+/** The table's own chrome: paging, copy, the chart/table toggle, notices. */
+export const COACH_RESULT_UI_KEYS = {
+ other: "coach.result.other",
+ noReading: "coach.result.noReading",
+ showAll: "coach.result.showAll",
+ showFewer: "coach.result.showFewer",
+ copyMenu: "coach.result.copyMenu",
+ copyForSpreadsheet: "coach.result.copyForSpreadsheet",
+ copyAsText: "coach.result.copyAsText",
+ copied: "coach.result.copied",
+ copyFailed: "coach.result.copyFailed",
+ viewLabel: "coach.result.viewLabel",
+ viewChart: "coach.result.viewChart",
+ viewTable: "coach.result.viewTable",
+ chartSummary: "coach.result.chartSummary",
+ dataUsed: "coach.result.dataUsed",
+ truncated: "coach.result.truncated",
+ loading: "coach.result.loading",
+ loadFailed: "coach.result.loadFailed",
+ histogramBin: "coach.result.histogramBin",
+} as const;
+
+/** Why a stored table is not shown. */
+export const COACH_RESULT_WITHHELD_KEYS = {
+ module_disabled: "coach.result.withheld.moduleDisabled",
+ unavailable: "coach.result.withheld.unavailable",
+} as const;
+
+// ── Method ────────────────────────────────────────────────────────────────
+
+export const COACH_METHOD_KEYS = {
+ label: "coach.method.label",
+ entry: "coach.method.entry",
+ entryNoWindow: "coach.method.entryNoWindow",
+} as const;
+
+/** "N readings" in the method line, by plural tier. */
+export function methodReadingsKey(count: number, locale: Locale): string {
+ return pluralKey("coach.method.readings", count, locale);
+}
+
+export const COACH_METHOD_AGGREGATION_KEYS: Readonly<
+ Record, string>
+> = {
+ mean: "coach.method.aggregation.mean",
+ median: "coach.method.aggregation.median",
+ latest: "coach.method.aggregation.latest",
+ sum: "coach.method.aggregation.sum",
+ count: "coach.method.aggregation.count",
+ rate: "coach.method.aggregation.rate",
+};
+
+/** "weekly averages" and "weekly totals", by granularity. */
+export function methodAveragesKey(granularity: CoachResultGranularity): string {
+ return `coach.method.averages.${granularity}`;
+}
+
+export function methodTotalsKey(granularity: CoachResultGranularity): string {
+ return `coach.method.totals.${granularity}`;
+}
+
+export const COACH_METHOD_ABSENT_KEYS: Readonly<
+ Record, string>
+> = {
+ no_data: "coach.method.absent.noData",
+ outside_window: "coach.method.absent.outsideWindow",
+ module_disabled: "coach.method.absent.moduleDisabled",
+};
+
+// ── Follow-ups ────────────────────────────────────────────────────────────
+
+/** One label per chip kind; `{metric}` on a related-metric chip. */
+export const COACH_FOLLOW_UP_KEYS: Readonly> =
+ {
+ widen_window: "coach.followUp.widenWindow",
+ previous_period: "coach.followUp.previousPeriod",
+ year_ago: "coach.followUp.yearAgo",
+ as_chart: "coach.followUp.asChart",
+ as_table: "coach.followUp.asTable",
+ related_metric: "coach.followUp.relatedMetric",
+ continue: "coach.followUp.continue",
+ };
+
+export const COACH_FOLLOW_UP_UI_KEYS = {
+ groupLabel: "coach.followUp.groupLabel",
+ settingLabel: "coach.followUp.settingLabel",
+ settingHint: "coach.followUp.settingHint",
+} as const;
+
+// ── Clarification ─────────────────────────────────────────────────────────
+
+export const COACH_CLARIFY_UI_KEYS = {
+ cardLabel: "coach.clarify.cardLabel",
+ choicesLabel: "coach.clarify.choicesLabel",
+ freeTextHint: "coach.clarify.freeTextHint",
+ contextHint: "coach.clarify.contextHint",
+} as const;
+
+/** A window choice on a clarification card ("Last 30 days"). */
+export function clarifyWindowLabelKey(window: CoachScopeWindow): string {
+ return `coach.clarify.window.${window}`;
+}
+
+// ── Reuse ─────────────────────────────────────────────────────────────────
+
+/** The prose of a turn answered from a stored table without a model call. */
+export const COACH_REUSE_CAPTION_KEY = "coach.reuse.caption";
From 0598b58f0e4278ba1a210048cab119d207afec58 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 10:57:53 +0200
Subject: [PATCH 7/8] feat(coach): client slots for steps, tables, method,
chips and clarification
The streaming hook handles the step, result, followUps and clarification
frames and sends followUp and clarification on the tool route only. The
bubble gains slots for the step list, the tables and the method line; the
thread offers chips under the latest assistant turn only; the conversation
shows the clarification card above the composer and marks a typed answer
as answering it. The tables of a persisted message load lazily when it
scrolls into view, seeded from the stream for the turn that produced them.
The new components render nothing yet.
---
.../__tests__/message-thread.test.tsx | 4 +
.../__tests__/use-coach-dialog.test.ts | 180 ++++++++++++++++++
.../insights/coach-panel/chat-bubble.tsx | 44 ++++-
.../coach-panel/clarification-card.tsx | 27 +++
.../coach-panel/coach-conversation.tsx | 58 +++++-
.../insights/coach-panel/coach-results.tsx | 53 ++++++
.../insights/coach-panel/follow-up-chips.tsx | 23 +++
.../insights/coach-panel/message-thread.tsx | 70 ++++++-
.../insights/coach-panel/method-line.tsx | 18 ++
.../insights/coach-panel/result-table.tsx | 18 ++
.../insights/coach-panel/turn-steps.tsx | 20 ++
.../insights/coach-panel/use-coach.ts | 136 +++++++++----
src/hooks/use-coach-message-results.ts | 74 +++++++
src/lib/query-keys/coach.ts | 14 ++
14 files changed, 700 insertions(+), 39 deletions(-)
create mode 100644 src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
create mode 100644 src/components/insights/coach-panel/clarification-card.tsx
create mode 100644 src/components/insights/coach-panel/coach-results.tsx
create mode 100644 src/components/insights/coach-panel/follow-up-chips.tsx
create mode 100644 src/components/insights/coach-panel/method-line.tsx
create mode 100644 src/components/insights/coach-panel/result-table.tsx
create mode 100644 src/components/insights/coach-panel/turn-steps.tsx
create mode 100644 src/hooks/use-coach-message-results.ts
diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index 114883526..1518762b8 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -77,6 +77,10 @@ function streaming(
metricSource: null,
suggestion: null,
suggestedAction: null,
+ steps: [],
+ results: [],
+ followUps: [],
+ clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
diff --git a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
new file mode 100644
index 000000000..add56be66
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
@@ -0,0 +1,180 @@
+/**
+ * v1.39.4 — the client end of the dialog frames: step upserts, the request
+ * fields a chip or an answered question adds, and which message the chips
+ * and the clarification belong to.
+ */
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({ t: (k: string) => k }),
+}));
+
+import type {
+ CoachMessageDTO,
+ CoachStep,
+ CoachStreamEvent,
+} from "@/lib/ai/coach/types";
+
+import {
+ parseSseChunk,
+ resolveCoachSendTarget,
+ upsertStep,
+ type CoachStreamingMessage,
+} from "../use-coach";
+import { latestClarification, latestFollowUps } from "../message-thread";
+
+const STEP: CoachStep = {
+ id: "s1",
+ tool: "get_sleep",
+ labelKey: "coach.step.read",
+ label: "Reading Sleep",
+ domain: "sleep",
+ status: "running",
+};
+
+const CHIP = {
+ id: "f1",
+ kind: "as_table" as const,
+ labelKey: "coach.followUp.asTable",
+ label: "Show as a table",
+ reuse: true,
+ origin: "server" as const,
+};
+
+const CLARIFY = { kind: "window" as const, choices: [], freeText: true };
+
+function streaming(
+ partial: Partial,
+): CoachStreamingMessage {
+ return {
+ content: "",
+ metricSource: null,
+ suggestion: null,
+ suggestedAction: null,
+ steps: [],
+ results: [],
+ followUps: [],
+ clarification: null,
+ inProgress: false,
+ messageId: null,
+ errorCode: null,
+ usage: null,
+ ...partial,
+ };
+}
+
+function message(partial: Partial): CoachMessageDTO {
+ return {
+ id: "m1",
+ role: "assistant",
+ content: "reply",
+ createdAt: "2026-09-26T00:00:00.000Z",
+ metricSource: null,
+ providerType: "anthropic",
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ ...partial,
+ };
+}
+
+describe("dialog frames on the client", () => {
+ it("parses the new frames like any other", () => {
+ const frames: CoachStreamEvent[] = [
+ { type: "step", step: STEP },
+ { type: "followUps", followUps: [CHIP] },
+ { type: "clarification", clarification: CLARIFY },
+ ];
+ const { events } = parseSseChunk(
+ "",
+ frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""),
+ );
+ expect(events).toEqual(frames);
+ });
+
+ it("upserts a step by id, keeping first-seen order", () => {
+ const second = { ...STEP, id: "s2" };
+ let steps = upsertStep([], STEP);
+ steps = upsertStep(steps, second);
+ steps = upsertStep(steps, { ...STEP, status: "done", count: 12 });
+ expect(steps.map((s) => [s.id, s.status])).toEqual([
+ ["s1", "done"],
+ ["s2", "running"],
+ ]);
+ });
+});
+
+describe("resolveCoachSendTarget — chips and answers", () => {
+ it("carries followUp and clarification on the tool route", () => {
+ const target = resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "Show as a table",
+ followUp: { messageId: "m1", id: "f1" },
+ clarification: { messageId: "m0", choiceId: "c2" },
+ });
+ expect(target.url).toBe("/api/insights/chat");
+ expect(JSON.parse(target.body)).toMatchObject({
+ followUp: { messageId: "m1", id: "f1" },
+ clarification: { messageId: "m0", choiceId: "c2" },
+ });
+ });
+
+ it("never carries them to the fenced route", () => {
+ const target = resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "x",
+ fenced: true,
+ followUp: { messageId: "m1", id: "f1" },
+ clarification: { messageId: "m0" },
+ });
+ expect(target.url).toBe("/api/insights/chat/fenced");
+ const body = JSON.parse(target.body);
+ expect(body).not.toHaveProperty("followUp");
+ expect(body).not.toHaveProperty("clarification");
+ });
+});
+
+describe("which message the chips and the question belong to", () => {
+ const withChips = message({
+ metricSource: {
+ windows: [],
+ metrics: [],
+ followUps: [CHIP],
+ clarification: CLARIFY,
+ },
+ });
+
+ it("offers the latest persisted assistant message's chips", () => {
+ expect(latestFollowUps([withChips], undefined)).toEqual({
+ messageId: "m1",
+ followUps: [CHIP],
+ });
+ expect(latestClarification([withChips], undefined)).toEqual({
+ messageId: "m1",
+ clarification: CLARIFY,
+ });
+ });
+
+ it("offers nothing once the person replied, and nothing mid-turn", () => {
+ const answered = [withChips, message({ id: "m2", role: "user" })];
+ expect(latestFollowUps(answered, undefined)).toBeNull();
+ expect(latestClarification(answered, undefined)).toBeNull();
+ expect(
+ latestFollowUps([withChips], streaming({ inProgress: true })),
+ ).toBeNull();
+ });
+
+ it("prefers the just-settled streamed turn over an older message", () => {
+ const live = streaming({
+ messageId: "m9",
+ followUps: [{ ...CHIP, id: "f2" }],
+ clarification: CLARIFY,
+ });
+ expect(latestFollowUps([withChips], live)?.messageId).toBe("m9");
+ expect(latestClarification([withChips], live)?.messageId).toBe("m9");
+ // A settled streamed turn without chips hides an older message's.
+ expect(
+ latestFollowUps([withChips], streaming({ messageId: "m9" })),
+ ).toBeNull();
+ });
+});
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index 02057310a..7cd012496 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -41,6 +41,9 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { MessageTokenFooter } from "./message-token-footer";
+import { CoachTurnSteps } from "./turn-steps";
+import { CoachResults } from "./coach-results";
+import { CoachMethodLine } from "./method-line";
import {
COACH_ICON_BUTTON,
ReadAloudButton,
@@ -387,6 +390,18 @@ interface ChatBubbleProps {
*/
suggestedAction?:
import("@/lib/ai/coach/suggest-action").CoachSuggestedAction | null;
+ /**
+ * v1.39.4 — live steps from the streaming hook. Persisted messages carry
+ * them on `metricSource.steps`; the bubble falls back to that.
+ */
+ steps?: import("@/lib/ai/coach/types").CoachStep[];
+ /**
+ * v1.39.4 — live tables from the streaming hook. A persisted message
+ * lists their metadata on `metricSource.results` and the values are
+ * fetched lazily, which needs the conversation id.
+ */
+ results?: import("@/lib/ai/coach/types").CoachResultTable[];
+ conversationId?: string | null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
@@ -464,6 +479,9 @@ export function areChatBubblePropsEqual(
prev.metricSource === next.metricSource &&
prev.suggestion === next.suggestion &&
prev.suggestedAction === next.suggestedAction &&
+ prev.steps === next.steps &&
+ prev.results === next.results &&
+ prev.conversationId === next.conversationId &&
prev.usage === next.usage &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
@@ -476,6 +494,9 @@ function ChatBubbleImpl({
metricSource,
suggestion,
suggestedAction,
+ steps,
+ results,
+ conversationId,
providerType,
inProgress,
errorCode,
@@ -654,7 +675,8 @@ function ChatBubbleImpl({
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
- const hasProvenance = hasChips || keyValues.length > 0;
+ const method = metricSource?.method ?? null;
+ const hasProvenance = hasChips || keyValues.length > 0 || method !== null;
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
// grounded `metric:` chart under a SETTLED assistant turn. Skipped
@@ -693,6 +715,12 @@ function ChatBubbleImpl({
)}
+ {/* v1.39.4 — what the Coach read on this turn: live from the step
+ frames, restored from the provenance on reload. */}
+
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
yet, restoring the writing/typing indicator the maintainer
@@ -768,6 +796,17 @@ function ChatBubbleImpl({
})}
)}
+ {/* v1.39.4 — the tables the turn read, on a settled turn only: live
+ from the result frames, or fetched lazily for a persisted
+ message whose provenance lists them. */}
+ {!inProgress && !errorCode && (
+
+ )}
{/* v1.18.6 — a "no provider configured anywhere" turn is a
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
@@ -874,6 +913,9 @@ function ChatBubbleImpl({
))}
)}
+ {/* v1.39.4 — how the answer was worked out, beside the key
+ values it explains. */}
+
)}
diff --git a/src/components/insights/coach-panel/clarification-card.tsx b/src/components/insights/coach-panel/clarification-card.tsx
new file mode 100644
index 000000000..ca8aac2a9
--- /dev/null
+++ b/src/components/insights/coach-panel/clarification-card.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+/**
+ * v1.39.4 — the choices for a clarifying question, above the composer.
+ * The question itself is the assistant reply; a tap on a choice sends its
+ * label with `clarification: { messageId, choiceId }`, and a typed answer
+ * goes out with `clarification: { messageId }`.
+ *
+ * Not built yet: renders nothing.
+ */
+import type {
+ CoachClarification,
+ CoachClarificationChoice,
+} from "@/lib/ai/coach/types";
+
+export interface CoachClarificationCardProps {
+ clarification: CoachClarification;
+ /** The assistant message that asked. */
+ messageId: string;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ onChoose: (choice: CoachClarificationChoice) => void;
+}
+
+export function CoachClarificationCard(_props: CoachClarificationCardProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index d4cb5b493..7f2d30cf9 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -18,7 +18,11 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
-import type { CoachScope } from "@/lib/ai/coach/types";
+import type {
+ CoachClarificationChoice,
+ CoachFollowUp,
+ CoachScope,
+} from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -45,7 +49,12 @@ import {
guidedReducer,
} from "./guided-questions-machine";
import { HistoryRail } from "./history-rail";
-import { MessageThread, type InterleavedThreadItem } from "./message-thread";
+import {
+ MessageThread,
+ latestClarification,
+ type InterleavedThreadItem,
+} from "./message-thread";
+import { CoachClarificationCard } from "./clarification-card";
import { MobileRailTray } from "./mobile-rail-tray";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
@@ -606,6 +615,37 @@ export function CoachConversation({
registerConversationIdGetter(() => conversationIdRef.current);
}, [registerConversationIdGetter]);
+ // v1.39.4 — the open clarifying question, if the latest reply asked one.
+ // A typed message answers it; a chip on the card answers it with a choice.
+ const openClarification = fenced
+ ? null
+ : latestClarification(conversation?.messages ?? [], send.streaming);
+
+ // v1.39.4 — a follow-up chip: its label is the message, and the server
+ // resolves what it asks for from the message that offered it.
+ function handleFollowUp(followUp: CoachFollowUp, messageId: string) {
+ if (send.isStreaming || fenced || currentConversationId === null) return;
+ void send.send({
+ conversationId: currentConversationId,
+ message: followUp.label,
+ followUp: { messageId, id: followUp.id },
+ });
+ }
+
+ // v1.39.4 — a choice on the clarification card.
+ function handleClarificationChoice(choice: CoachClarificationChoice) {
+ if (send.isStreaming || !openClarification) return;
+ if (currentConversationId === null) return;
+ void send.send({
+ conversationId: currentConversationId,
+ message: choice.label,
+ clarification: {
+ messageId: openClarification.messageId,
+ choiceId: choice.id,
+ },
+ });
+ }
+
async function handleSubmit(value: string) {
const trimmed = value.trim();
if (!trimmed || send.isStreaming) return;
@@ -654,6 +694,10 @@ export function CoachConversation({
guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined),
scope: fenced ? undefined : scope,
workoutId: fenced ? undefined : workoutId,
+ // v1.39.4 — a typed message answers an open clarifying question.
+ clarification: openClarification
+ ? { messageId: openClarification.messageId }
+ : undefined,
fenced,
pendingAttachmentIds:
currentConversationId === null ? pendingAttachmentIds : undefined,
@@ -817,6 +861,14 @@ export function CoachConversation({
{t("insights.coach.attach.indexingHint")}
) : null}
+ {openClarification ? (
+
+ ) : null}
{/* Docked composer — the SAME centred, capped column as the
@@ -1220,6 +1273,7 @@ export function CoachConversation({
optimisticUser={send.optimisticUser}
interleaved={interleaved}
onRegenerate={handleRegenerate}
+ onFollowUp={handleFollowUp}
/>
}
composer={composerStack}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
new file mode 100644
index 000000000..8342434ff
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+/**
+ * v1.39.4 — the tables under an assistant reply. A live turn hands its
+ * tables in from the `result` frames; a persisted message lists only their
+ * metadata, and the values are fetched when the message scrolls into view
+ * (`useCoachMessageResults`). A withheld table is skipped here; the notice
+ * for it belongs to the table view.
+ */
+import { useCoachMessageResults } from "@/hooks/use-coach-message-results";
+import type {
+ CoachResultMeta,
+ CoachResultTable as CoachResultTableData,
+} from "@/lib/ai/coach/types";
+
+import { CoachResultTable } from "./result-table";
+
+export interface CoachResultsProps {
+ conversationId: string | null;
+ /** The persisted message, once it has an id. */
+ messageId: string | null;
+ /** `metricSource.results` of the message. */
+ metas: CoachResultMeta[];
+ /** The tables a live turn streamed; absent on a persisted message. */
+ live?: CoachResultTableData[];
+}
+
+export function CoachResults({
+ conversationId,
+ messageId,
+ metas,
+ live,
+}: CoachResultsProps) {
+ const hasLive = (live?.length ?? 0) > 0;
+ const { ref, results } = useCoachMessageResults({
+ conversationId,
+ messageId,
+ enabled: !hasLive && metas.length > 0,
+ });
+ if (!hasLive && metas.length === 0) return null;
+ const tables: CoachResultTableData[] = hasLive
+ ? (live ?? [])
+ : (results ?? []).filter(
+ (entry): entry is CoachResultTableData => !("withheld" in entry),
+ );
+ return (
+
+ {tables.map((table) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx
new file mode 100644
index 000000000..8d14cf772
--- /dev/null
+++ b/src/components/insights/coach-panel/follow-up-chips.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+/**
+ * v1.39.4 — up to three follow-up chips under the latest assistant reply.
+ * A tap sends the chip's label as the message with
+ * `followUp: { messageId, id }`; the server resolves what the chip asks for.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachFollowUp } from "@/lib/ai/coach/types";
+
+export interface CoachFollowUpChipsProps {
+ followUps: CoachFollowUp[];
+ /** The assistant message that offered the chips. */
+ messageId: string;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ onSelect: (followUp: CoachFollowUp, messageId: string) => void;
+}
+
+export function CoachFollowUpChips(_props: CoachFollowUpChipsProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index feda6a3cb..90d6a6763 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -9,12 +9,17 @@ import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
import { ChatBubble } from "./chat-bubble";
+import { CoachFollowUpChips } from "./follow-up-chips";
import type {
CoachConversationDetailDTO,
CoachOptimisticUserMessage,
CoachStreamingMessage,
} from "./use-coach";
-import type { CoachMessageDTO } from "@/lib/ai/coach/types";
+import type {
+ CoachClarification,
+ CoachFollowUp,
+ CoachMessageDTO,
+} from "@/lib/ai/coach/types";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -72,6 +77,54 @@ export interface MessageThreadProps {
* resubmits it as a fresh turn. Omitted → the regenerate action is hidden.
*/
onRegenerate?: (userText: string) => void;
+ /**
+ * v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown.
+ */
+ onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
+}
+
+/**
+ * v1.39.4 — the open clarifying question: the latest assistant turn's
+ * choices, whether it is the just-settled streamed turn or the last
+ * persisted message. None while a turn is in flight, and none once the
+ * person has answered (their message is then the last one).
+ */
+export function latestClarification(
+ messages: CoachMessageDTO[],
+ streaming: CoachStreamingMessage | undefined,
+): { messageId: string; clarification: CoachClarification } | null {
+ if (streaming?.inProgress) return null;
+ if (streaming?.messageId && streaming.clarification) {
+ return {
+ messageId: streaming.messageId,
+ clarification: streaming.clarification,
+ };
+ }
+ const last = messages[messages.length - 1];
+ if (streaming?.messageId && streaming.messageId !== last?.id) return null;
+ if (last?.role !== "assistant") return null;
+ const clarification = last.metricSource?.clarification;
+ return clarification ? { messageId: last.id, clarification } : null;
+}
+
+/**
+ * v1.39.4 — the chips to offer, and the message that offered them: the
+ * latest assistant turn only, whether it is the just-settled streamed turn
+ * or the last persisted message. None while a turn is in flight.
+ */
+export function latestFollowUps(
+ messages: CoachMessageDTO[],
+ streaming: CoachStreamingMessage | undefined,
+): { messageId: string; followUps: CoachFollowUp[] } | null {
+ if (streaming?.inProgress) return null;
+ if (streaming?.messageId && streaming.followUps.length > 0) {
+ return { messageId: streaming.messageId, followUps: streaming.followUps };
+ }
+ const last = messages[messages.length - 1];
+ if (streaming?.messageId && streaming.messageId !== last?.id) return null;
+ if (last?.role !== "assistant") return null;
+ const followUps = last.metricSource?.followUps ?? [];
+ return followUps.length > 0 ? { messageId: last.id, followUps } : null;
}
/**
@@ -167,6 +220,7 @@ export function MessageThread({
emptyHint,
interleaved,
onRegenerate,
+ onFollowUp,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef(null);
@@ -253,6 +307,8 @@ export function MessageThread({
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
+ const chips = latestFollowUps(messages, streaming);
+
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
useEffect(() => {
@@ -385,6 +441,7 @@ export function MessageThread({
role={m.role}
content={m.content}
metricSource={m.metricSource}
+ conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
@@ -437,6 +494,8 @@ export function MessageThread({
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
+ steps={streaming.steps}
+ results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -446,6 +505,15 @@ export function MessageThread({
/>
)}
+ {/* v1.39.4 — follow-up chips under the latest assistant turn only. */}
+ {onFollowUp && chips ? (
+
+ ) : null}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
diff --git a/src/components/insights/coach-panel/method-line.tsx b/src/components/insights/coach-panel/method-line.tsx
new file mode 100644
index 000000000..e1f135fe0
--- /dev/null
+++ b/src/components/insights/coach-panel/method-line.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+/**
+ * v1.39.4 — how an answer was worked out, as one muted line inside the
+ * evidence disclosure: sources, windows, counts and aggregation, rendered
+ * on the server. Never a health value.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachMethod } from "@/lib/ai/coach/types";
+
+export interface CoachMethodLineProps {
+ method: CoachMethod | null;
+}
+
+export function CoachMethodLine(_props: CoachMethodLineProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx
new file mode 100644
index 000000000..591d5b378
--- /dev/null
+++ b/src/components/insights/coach-panel/result-table.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+/**
+ * v1.39.4 — one table of values a Coach turn read: a captioned table with
+ * the first rows shown, the rest behind "Show all", and copy for a
+ * spreadsheet or as text.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachResultTable as CoachResultTableData } from "@/lib/ai/coach/types";
+
+export interface CoachResultTableProps {
+ result: CoachResultTableData;
+}
+
+export function CoachResultTable(_props: CoachResultTableProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx
new file mode 100644
index 000000000..e066d4245
--- /dev/null
+++ b/src/components/insights/coach-panel/turn-steps.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+/**
+ * v1.39.4 — what the Coach read on a turn: a compact list of steps under
+ * the assistant avatar, live while the turn runs (from the `step` frames)
+ * and restored from `metricSource.steps` on reload.
+ *
+ * Not built yet: renders nothing.
+ */
+import type { CoachStep } from "@/lib/ai/coach/types";
+
+export interface CoachTurnStepsProps {
+ steps: CoachStep[];
+ /** True while the turn is still running. */
+ active: boolean;
+}
+
+export function CoachTurnSteps(_props: CoachTurnStepsProps) {
+ return null;
+}
diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts
index a46aff9ef..75df5b9a0 100644
--- a/src/components/insights/coach-panel/use-coach.ts
+++ b/src/components/insights/coach-panel/use-coach.ts
@@ -11,11 +11,15 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
+ CoachClarification,
CoachConversationAttachmentDTO,
CoachConversationDetailDTO,
CoachConversationsPage,
+ CoachFollowUp,
CoachProvenance,
+ CoachResultTable,
CoachScope,
+ CoachStep,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
@@ -539,6 +543,21 @@ export interface CoachStreamingMessage {
* on `metricSource.suggestedAction` instead. Null otherwise.
*/
suggestedAction: CoachSuggestedAction | null;
+ /**
+ * v1.39.4 — the live steps from the additive `step` frames, upserted by
+ * id in first-seen order. Persisted messages carry them on
+ * `metricSource.steps`.
+ */
+ steps: CoachStep[];
+ /**
+ * v1.39.4 — the tables from the additive `result` frames. Persisted
+ * messages carry only their metadata; the values are fetched lazily.
+ */
+ results: CoachResultTable[];
+ /** v1.39.4 — the chips from the additive `followUps` frame. */
+ followUps: CoachFollowUp[];
+ /** v1.39.4 — the choices from the additive `clarification` frame. */
+ clarification: CoachClarification | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -617,12 +636,25 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
metricSource: null,
suggestion: null,
suggestedAction: null,
+ steps: [],
+ results: [],
+ followUps: [],
+ clarification: null,
inProgress: false,
messageId: null,
errorCode: null,
usage: null,
};
+/** v1.39.4 — insert a step, or replace the one with the same id in place. */
+export function upsertStep(steps: CoachStep[], step: CoachStep): CoachStep[] {
+ const at = steps.findIndex((s) => s.id === step.id);
+ if (at === -1) return [...steps, step];
+ const next = [...steps];
+ next[at] = step;
+ return next;
+}
+
export interface SendCoachMessageParams {
conversationId?: string;
message: string;
@@ -665,6 +697,18 @@ export interface SendCoachMessageParams {
* client re-sent it.
*/
workoutId?: string;
+ /**
+ * v1.39.4 — the follow-up chip this message came from: the assistant
+ * message that offered it and the chip's id. The server resolves the chip
+ * from what it persisted; the client never sends what the chip asks for.
+ * Tool route only.
+ */
+ followUp?: { messageId: string; id: string };
+ /**
+ * v1.39.4 — the clarifying question this message answers, and the chip
+ * tapped (absent when the person typed their own answer). Tool route only.
+ */
+ clarification?: { messageId: string; choiceId?: string };
}
/**
@@ -712,6 +756,8 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
scope: params.scope,
guidedQuestion: params.guidedQuestion,
workoutId: params.workoutId,
+ followUp: params.followUp,
+ clarification: params.clarification,
}),
};
}
@@ -857,14 +903,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
conversationId: params.conversationId ?? null,
});
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
+ ...EMPTY_STREAMING,
inProgress: true,
- messageId: null,
- errorCode: null,
- usage: null,
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -872,14 +912,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// waiting for the fetch to fail with a generic network error.
if (typeof navigator !== "undefined" && navigator.onLine === false) {
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
errorCode: "coach.network",
- usage: null,
});
return null;
}
@@ -905,28 +939,16 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
} catch (err) {
if ((err as Error).name === "AbortError") return null;
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
errorCode: "coach.network",
- usage: null,
});
return null;
}
if (!response.body) {
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
errorCode: `coach.http.${response.status}`,
- usage: null,
});
return null;
}
@@ -963,14 +985,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
// body was not JSON; fall through to the http-status fallback
}
setStreaming({
- content: "",
- metricSource: null,
- suggestion: null,
- suggestedAction: null,
- inProgress: false,
- messageId: null,
+ ...EMPTY_STREAMING,
errorCode: structured ?? `coach.http.${response.status}`,
- usage: null,
});
// v1.16.4 — KEEP the optimistic user bubble: a rejected turn
// (budget gate, 4xx) has no persisted twin coming, and dropping
@@ -988,6 +1004,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedProvenance: CoachProvenance | null = null;
let collectedSuggestion: CoachSuggestion | null = null;
let collectedSuggestedAction: CoachSuggestedAction | null = null;
+ let collectedSteps: CoachStep[] = [];
+ const collectedResults: CoachResultTable[] = [];
+ let collectedFollowUps: CoachFollowUp[] = [];
+ let collectedClarification: CoachClarification | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
@@ -1029,6 +1049,39 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestedAction: evt.suggestedAction,
}));
break;
+ case "step": {
+ // v1.39.4 — live progress, upserted by step id.
+ const step = evt.step;
+ collectedSteps = upsertStep(collectedSteps, step);
+ setStreaming((prev) => ({
+ ...prev,
+ steps: upsertStep(prev.steps, step),
+ }));
+ break;
+ }
+ case "result": {
+ const result = evt.result;
+ collectedResults.push(result);
+ setStreaming((prev) => ({
+ ...prev,
+ results: [...prev.results, result],
+ }));
+ break;
+ }
+ case "followUps":
+ collectedFollowUps = evt.followUps;
+ setStreaming((prev) => ({
+ ...prev,
+ followUps: evt.followUps,
+ }));
+ break;
+ case "clarification":
+ collectedClarification = evt.clarification;
+ setStreaming((prev) => ({
+ ...prev,
+ clarification: evt.clarification,
+ }));
+ break;
case "reasoning":
// v1.19.1 — the thinking disclosure was removed, so the
// additive `reasoning` frame is accepted off the wire and
@@ -1069,6 +1122,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
suggestedAction: collectedSuggestedAction,
+ steps: collectedSteps,
+ results: collectedResults,
+ followUps: collectedFollowUps,
+ clarification: collectedClarification,
inProgress: false,
messageId,
errorCode: lastError,
@@ -1083,6 +1140,15 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
queryKey: QUERY_KEYS.one(resolvedConversationId),
});
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() });
+ // v1.39.4 — the tables this turn streamed are exactly what the
+ // persisted message stores, so seed its lazy read rather than
+ // decrypting them again on the server when the twin lands.
+ if (messageId && collectedResults.length > 0) {
+ queryClient.setQueryData(
+ queryKeys.coachMessageResults(resolvedConversationId, messageId),
+ collectedResults,
+ );
+ }
optsRef.current.onDone?.(resolvedConversationId);
}
// v1.4.25 W5 — drop the optimistic user bubble; the persisted
diff --git a/src/hooks/use-coach-message-results.ts b/src/hooks/use-coach-message-results.ts
new file mode 100644
index 000000000..3aeafe801
--- /dev/null
+++ b/src/hooks/use-coach-message-results.ts
@@ -0,0 +1,74 @@
+"use client";
+
+/**
+ * v1.39.4 — the stored tables of one Coach message, fetched lazily.
+ *
+ * A thread can hold many messages with tables, and each table set is
+ * decrypted on the server per request, so nothing is fetched until the
+ * message scrolls near the viewport: mount the returned `ref` on the
+ * element that shows the tables. `enabled` gates the whole read (a message
+ * whose provenance lists no tables never asks). Once fetched, the tables are
+ * the stored snapshot and do not change, so they stay fresh indefinitely.
+ */
+import { useEffect, useState, type RefCallback } from "react";
+import { useQuery } from "@tanstack/react-query";
+
+import type { CoachResultEntry } from "@/lib/ai/coach/types";
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+
+export function useCoachMessageResults(args: {
+ conversationId: string | null;
+ messageId: string | null;
+ enabled: boolean;
+}): {
+ ref: RefCallback;
+ results: CoachResultEntry[] | undefined;
+ isLoading: boolean;
+ isError: boolean;
+} {
+ const { conversationId, messageId, enabled } = args;
+ const [node, setNode] = useState(null);
+ const [visible, setVisible] = useState(false);
+
+ // Without an observer (an old engine) there is nothing to wait for.
+ const canObserve = typeof IntersectionObserver !== "undefined";
+
+ useEffect(() => {
+ if (!enabled || visible || !node || !canObserve) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) setVisible(true);
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [enabled, visible, node, canObserve]);
+
+ const query = useQuery({
+ queryKey: queryKeys.coachMessageResults(
+ conversationId ?? "",
+ messageId ?? "",
+ ),
+ queryFn: async (): Promise => {
+ const data = await apiGet<{ results: CoachResultEntry[] }>(
+ `/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/results`,
+ );
+ return data.results;
+ },
+ enabled:
+ enabled &&
+ (visible || !canObserve) &&
+ conversationId !== null &&
+ messageId !== null,
+ staleTime: Infinity,
+ });
+
+ return {
+ ref: setNode,
+ results: query.data,
+ isLoading: query.isLoading,
+ isError: query.isError,
+ };
+}
diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts
index cf336d738..5e6ba1138 100644
--- a/src/lib/query-keys/coach.ts
+++ b/src/lib/query-keys/coach.ts
@@ -48,6 +48,20 @@ export const coachKeys = {
*/
coachAttachmentMutation: (conversationId: string) =>
["coachConversation", conversationId, "attachments"] as const,
+ /**
+ * v1.39.4 — the stored tables of one assistant message
+ * (`GET /api/insights/chat/{id}/messages/{messageId}/results`). Nested under
+ * the conversation key so deleting or invalidating the conversation reaches
+ * every message's tables with the existing prefix match.
+ */
+ coachMessageResults: (conversationId: string, messageId: string) =>
+ [
+ "coachConversation",
+ conversationId,
+ "messages",
+ messageId,
+ "results",
+ ] as const,
/**
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
* Coach hero's pre-seeded relevance opener
From 1e9b0aa7ff185d4aa103c213d6fb357b4ee35aad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 27 Sep 2026 11:02:14 +0200
Subject: [PATCH 8/8] test(api): list the stored-tables route among the
per-table module-gated reads
---
src/app/api/__tests__/module-route-gate-inventory.test.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index 7bd394e83..627962fe8 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -337,6 +337,11 @@ const EXEMPT_ROUTES: ReadonlyArray = [
// entry here would fail the "does not hide a gate" check below.
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
+ // One message's stored tables. No whole-route gate for the same reason,
+ // but each table is withheld when the module owning its domain is off
+ // (`isCoachDomainWithheld` over the resolved module map), so a switched-off
+ // domain's values never come back through a stored answer.
+ "src/app/api/insights/chat/[id]/messages/[messageId]/results/route.ts",
// The Today digest and its dismiss: data, with the AI parts masked by the
// loader through the capability resolver.
"src/app/api/daily/digest/route.ts",