diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b73a874a5..e427a246d 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:
@@ -37240,6 +37321,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
@@ -49960,6 +50050,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:
@@ -50063,9 +51328,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
@@ -50097,82 +51364,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/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/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 963b5ce71..d24090870 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -6237,6 +6237,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 db3ef9348..513bbbe90 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/__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/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/__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",
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/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/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/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/__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/__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/__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/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/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";
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/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/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/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/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/stream-events.ts b/src/lib/ai/coach/stream-events.ts
new file mode 100644
index 000000000..c30ce16d9
--- /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: "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`.",
+ });
+
+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/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/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/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.
+ }
+}
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/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;
}
/**
diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts
index 56c1b5c55..bb280d110 100644
--- a/src/lib/crypto/encrypted-columns.ts
+++ b/src/lib/crypto/encrypted-columns.ts
@@ -222,6 +222,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/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"],
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
diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts
index 40fbd9397..76bdb532a 100644
--- a/src/lib/validations/backup.ts
+++ b/src/lib/validations/backup.ts
@@ -1357,6 +1357,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 a9cae4e6b..be6962acc 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,
@@ -1580,6 +1621,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),
})),
@@ -1597,12 +1641,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: [],
@@ -1619,6 +1665,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],
@@ -2699,6 +2746,75 @@ 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 { coachConversations } = payload as {
+ coachConversations: Array<{ messages: Array> }>;
+ };
+ const [turn] = 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);