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] 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");