diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea674146..689422754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## [1.41.0] — 2026-10-07 + +The Coach reasons before it answers, shows what it is doing while it works, +asks when a question is ambiguous, and remembers what it is told. + +### Added + +- **Reasoning.** Codex, OpenAI, Anthropic, OpenRouter, gateways and local + servers receive the requested reasoning in their documented dialect; + reasoning summaries stream to the Coach, signed or encrypted reasoning state + is carried between tool rounds and never stored, and a model that rejects a + reasoning parameter is retried without it (the downgrade is learned per + provider, endpoint and model for an hour, only from a 400 that names the + parameter). Thinking depth per person under Coach settings (Off, Low, + Medium, High; Medium by default); an operator switch and maximum under + Admin, Assistant; turns on the operator's budget are capped at Medium. + `/api/auth/me` publishes the resolved `coachReasoning`. +- **Budgeted tool loop.** A turn runs against a token, time and round budget + (own provider 120k tokens, 150 s, 12 rounds; operator 40k, 90 s, 6 rounds) + with a no-progress brake and a reserved final round that keeps the tool + definitions with `tool_choice: none`. Every round is reserved against the + daily budget beforehand and settled afterwards. At most two concurrent turns + per person. +- **Live trail.** One quiet status line per answer (`activity` frames), never + expanded by itself; the screened reasoning text is stored encrypted + (`coach_messages.trail_encrypted`) and served lazily by + `GET /api/insights/chat/{id}/messages/{messageId}/trail`. +- **Comparisons.** `compare_series` (two periods or two metrics) and a + `compare` chart; tables arrive as interim results before the answer. +- **Clarifications** as suggested replies in the thread, at most one in six + turns and three a day; a declined or limited question becomes a stated + assumption with a reply that changes it. +- **Memory and plans.** Every turn carries an egress-checked memory block; + `remember_fact` saves preferences, goals and context with a visible note and + undo, health facts only after the person agrees; `propose_plan` suggests a + plan from a stated goal. Both are written only when the answer that shows + them is stored. Background extraction runs once a conversation is quiet for + 30 minutes; plan progress reaches the daily briefing. The memory list in + settings shows each fact's source and accepts edits. +- **Background reasoning** for the daily briefing (scheduled and on request) + and the weekly and monthly narratives; status cards never reason. + +### Fixed + +- **Dose-change screen.** The outbound screen blocks the dose-change class + across all seven languages, including the person's own medication names, + drug classes, spelled-out amounts and "extra / one more"; plan texts go + through the same screen. +- **Glucose by time of day.** The usual-range line, the MCP baseline and the + signals of the day compare glucose with the same hours of earlier days + instead of setting a morning reading against whole-day means. +- **Usual-range line** names the date of a latest reading that is not from + today, and a steady value as one figure. +- **Memory refresh** asks the Coach capability before deferring, so a + switched-off Coach does not re-queue it. +- **Dependencies.** The MCP SDK moves to 1.32 (GHSA-6qxp-vccf-f47h) and + `proxy-addr` to 2.0.8 (GHSA-jqcg-44mw-7w3h). + +### Changed + +- **Answer voice.** The first sentence answers; 40 to 120 words; at most one + next step. +- **Keyboard focus** is drawn in a neutral tone everywhere. + +### Migrations + +- `0376_ai_reasoning_controls`, `0377_coach_message_trail`, + `0378_coach_fact_source`: additive columns with constant defaults or + nullable; no table rewrite. + ## [1.40.6] — 2026-10-05 The Coach reads long series again; "today" only from today's readings; the diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 41a985a94..650820e4d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.40.6 + version: 1.41.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -9429,6 +9429,49 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/insights/chat/{id}/messages/{messageId}/trail: + get: + tags: + - Insights + summary: Read the trail text of one Coach message + description: "v1.41 — the text of one assistant message's trail: the screened reasoning titles and summaries by trail + entry (model-written), the fact texts the turn recalled, and a pending fact proposal (the person's own data). + The trail's structure (phases, statuses, counts) is plaintext on the message's `metricSource.activity`; this + route serves only the text, decrypted for the account that owns the conversation. Fetch lazily when the person + opens the trail. A mixed read: while the Coach's text may not be shown for the record, `entries` is empty and + the `ai` state rides beside it, but `recalled` and `proposal` are still served, since they are the person's + data. While the medications module is off, recalled facts and a proposal that concern a medication are left out. + `trail` is null when the message has no trail or nothing in it may be served. A foreign or unknown conversation + or message id maps to 404 (never 403). Auth via cookie or a full-access Bearer token." + 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 trail text, or null when there is none or nothing in it may be served. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachMessageTrailResponse" + "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: @@ -9768,11 +9811,12 @@ paths: tags: - Insights summary: List the caller's durable Coach facts - description: "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then - newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Never AI-gated: the - facts stay readable and erasable while the Coach is unavailable for any reason. Auth via cookie or Bearer; the - owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing - the read." + description: "v1.11.1 — returns the active facts the Coach keeps about the caller (highest-confidence then newest + first), each decrypted on the fly. The GDPR 'what do you know about me' surface. v1.41 — each fact carries its + source, the message it came from and when it was last used in a turn; a health fact still waiting for the + caller's confirmation is not listed. Never AI-gated: the facts stay readable and erasable while the Coach is + unavailable for any reason. Auth via cookie or Bearer; the owner is always narrowed from the session, never the + body. Undecryptable rows are omitted rather than failing the read." responses: "200": description: The caller's active facts. @@ -9783,6 +9827,51 @@ paths: "401": *a1 "422": *a4 "429": *a5 + post: + tags: + - Insights + summary: Remember one of the caller's own Coach messages + description: "v1.41 — the remember button. Saves the named user message as a fact with `source: \"user\"`: the text is + the message the server stored, filed as `medication` or `condition` when it names one, `context` otherwise. The + tap is the caller's confirmation, so a health message is saved too. A message the caller does not own, or an + assistant message, is a 404. Saving the same fact again returns the existing one with `created: false`. Gated on + the Coach module, never on AI. Auth via cookie or Bearer." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachFactCreateRequest" + responses: + "200": + description: The caller's memory already held this fact. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachFactAlreadyKnown" + "201": + description: The fact was saved. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachFactCreated" + "401": *a1 + "403": + description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = + `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a + valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: Message not found or not one of the caller's own. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -9800,6 +9889,44 @@ paths: "422": *a4 "429": *a5 /api/insights/coach/facts/{id}: + patch: + tags: + - Insights + summary: Edit one Coach fact's wording + description: "v1.41 — rewrites the text of a fact the caller owns (re-encrypted at rest) and files it by its new + wording: text that names a medication moves to `medication`, so the medications module keeps filtering it; other + health wording moves a non-health category to `condition`; otherwise the category stays. A health fact the + caller edited counts as confirmed (`source: \"user\"`). A proposal still waiting for the caller's confirmation, + an unknown, cross-user or deleted id are all a 404. Never AI-gated. Auth via cookie or Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Fact id. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CoachFactPatchRequest" + responses: + "200": + description: The fact after the edit. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachFactUpdated" + "401": *a1 + "404": + description: Fact not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 delete: tags: - Insights @@ -25979,6 +26106,16 @@ components: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean + reasoning: + description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means + `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET + /api/auth/me` as `coachReasoning`." + type: string + enum: + - off + - low + - medium + - high baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -26666,6 +26803,40 @@ components: maxLength: 8 required: - messageId + memoryDecision: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + proposalId: + type: string + minLength: 1 + maxLength: 64 + accept: + type: boolean + required: + - messageId + - proposalId + - accept + planDecision: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + planId: + type: string + minLength: 1 + maxLength: 64 + accept: + type: boolean + required: + - messageId + - planId + - accept required: - message description: "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a @@ -26863,6 +27034,31 @@ components: - version description: PUT body for the Insights layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write. + CoachFactCreateRequest: + type: object + properties: + messageId: + type: string + minLength: 1 + maxLength: 64 + required: + - messageId + additionalProperties: false + description: "v1.41 — the remember button: the id of one of the caller's own Coach messages. Never the text: the server + reads the message it stored. Strict: unknown keys 422." + CoachFactPatchRequest: + type: object + properties: + fact: + type: string + minLength: 3 + maxLength: 160 + required: + - fact + additionalProperties: false + description: "v1.41 — the fact's new wording (3..160 characters). Only the text: the server reads the new wording to + file it (a fact that names a medication moves to `medication`, other health wording moves a non-health fact to + `condition`) and a health fact the caller wrote counts as confirmed. Strict: unknown keys 422." UpdateEmergencyProfileRequest: type: object properties: @@ -34972,6 +35168,14 @@ components: type: boolean assistantDocumentAiEnabled: type: boolean + aiReasoningEnabled: + type: boolean + aiReasoningMaxEffort: + type: string + enum: + - low + - medium + - high moduleAvailabilityJson: anyOf: - {} @@ -38866,6 +39070,15 @@ components: anyOf: - type: string - type: "null" + trailEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + trailJson: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -39032,6 +39245,19 @@ components: anyOf: - type: string - type: "null" + source: + type: string + minLength: 1 + maxLength: 32 + lastUsedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + sourceMessageId: + anyOf: + - type: string + - type: "null" createdAt: type: string minLength: 1 @@ -48884,6 +49110,16 @@ components: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean + reasoning: + description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means + `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET + /api/auth/me` as `coachReasoning`." + type: string + enum: + - off + - low + - medium + - high updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or @@ -52165,6 +52401,12 @@ components: type: string usage: $ref: "#/components/schemas/CoachUsage" + stop: + $ref: "#/components/schemas/CoachStop" + withheldResults: + description: v1.41 — the turn was blocked after interim tables went out; remove them. + type: boolean + const: true required: - type - conversationId @@ -52212,6 +52454,10 @@ components: const: result result: $ref: "#/components/schemas/CoachResultTable" + interim: + description: v1.41 — sent while the turn still runs. + type: boolean + const: true required: - type - result @@ -52240,9 +52486,43 @@ components: - type - clarification additionalProperties: false + - type: object + properties: + type: + type: string + const: activity + activity: + $ref: "#/components/schemas/CoachActivity" + required: + - type + - activity + additionalProperties: false + - type: object + properties: + type: + type: string + const: memoryNote + note: + $ref: "#/components/schemas/CoachMemoryNote" + required: + - type + - note + additionalProperties: false + - type: object + properties: + type: + type: string + const: planProposal + proposal: + $ref: "#/components/schemas/CoachPlanProposal" + required: + - type + - proposal + 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." + Order: `(activity | step)*` → `token*` → `provenance` → `result*` → `suggestion?` → `suggestedAction?` → + `memoryNote?` → `planProposal?` → `clarification?` → `followUps?` → `done`, or a single `error`. `result` frames + with `interim: true` may also arrive among the `activity` frames. Clients ignore a `type` they do not know." type: object CoachProvenance: type: object @@ -52451,6 +52731,25 @@ components: that earlier assistant message. A continuation offers no further `continue` chip. Absent otherwise." type: string maxLength: 64 + activity: + description: v1.41 — the turn's trail, metadata only. + type: array + items: + $ref: "#/components/schemas/CoachActivityMeta" + stop: + description: v1.41 — why the answer was forced, when it was. + $ref: "#/components/schemas/CoachStop" + assumptions: + description: v1.41 — what the answer assumed instead of asking, at most two. + type: array + items: + $ref: "#/components/schemas/CoachAssumption" + memoryNote: + description: v1.41 — the fact this reply saved or proposes, without its text. + $ref: "#/components/schemas/CoachMemoryNoteMeta" + planProposal: + description: v1.41 — the plan this reply proposes, without its text. + $ref: "#/components/schemas/CoachPlanProposalMeta" required: - windows - metrics @@ -52530,7 +52829,7 @@ components: id: type: string pattern: ^s[1-9]\d{0,2}$ - description: "`s1`..`s12`, unique within a turn." + description: "`s1`..`s48`, unique within a turn." tool: type: string enum: @@ -52598,7 +52897,7 @@ components: resultRef: description: The table this step produced (`r1`..), when it produced one. type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ required: - id - tool @@ -52724,8 +53023,8 @@ components: properties: ref: type: string - pattern: ^r[1-6]$ - description: "`r1`..`r6`, unique within a message." + pattern: ^r[1-8]$ + description: "`r1`..`r8`, unique within a message." source: type: object properties: @@ -52791,6 +53090,7 @@ components: - type: string enum: - line + - compare - bar - histogram - type: "null" @@ -52807,7 +53107,7 @@ components: pattern: ^[A-Za-z0-9_-]{1,64}$ ref: type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ required: - messageId - ref @@ -52846,6 +53146,7 @@ components: - as_table - related_metric - continue + - change_assumption labelKey: type: string label: @@ -52856,7 +53157,7 @@ components: properties: ref: type: string - pattern: ^(?:r[1-6])?$ + pattern: ^(?:r[1-8])?$ domain: $ref: "#/components/schemas/CoachStepDomain" window: @@ -52891,6 +53192,91 @@ components: enum: - server - model + assumption: + description: "v1.41 — on a `change_assumption` chip: the assumption it replaces and the alternative it picks." + type: object + properties: + kind: + type: string + enum: + - metric + - window + - comparison + 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 + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." + required: + - kind + - value + additionalProperties: false required: - id - kind @@ -52909,7 +53295,11 @@ components: enum: - metric - window + - comparison + - goal + - anchor - context + description: "`comparison`, `goal` and `anchor` since v1.41." choices: type: array items: @@ -52976,7 +53366,23 @@ components: - last90days - lastYear - allTime + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." required: - id - labelKey @@ -52987,6 +53393,11 @@ components: freeText: type: boolean description: Whether a typed answer is welcome. + assumption: + description: v1.41 — the choice that applies when the person does not answer. The question names it, and it is the first + choice. + type: string + pattern: ^c[1-4]$ required: - kind - choices @@ -52994,6 +53405,328 @@ components: additionalProperties: false description: "The choices for a clarifying question. The question itself is the assistant message's text. Answer with `clarification: { messageId, choiceId? }`." + CoachActivityMeta: + type: object + properties: + id: + type: string + pattern: ^a[1-9]\d?$ + description: "`a1`..`a99`, unique within a turn." + phase: + type: string + enum: + - thinking + - memory + - fetch + - digest + - checkpoint + - remember + - plan + - asking + - stop + - answer + status: + type: string + enum: + - running + - done + - empty + - failed + round: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The tool round, from 1. + labelKey: + type: string + description: Closed catalog key (`insights.coach.activity.*`). + label: + type: string + description: Rendered on the server in the request locale from the catalog; never model text. + stepRef: + description: The `step` a `fetch` entry stands for. + type: string + pattern: ^s[1-9]\d{0,2}$ + count: + description: Readings, lookups or entries the server counted. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + durationMs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stop: + description: Set on the `stop` entry. + type: string + enum: + - budget + - time + - cap + - no_progress + required: + - id + - phase + - status + - round + - labelKey + - label + additionalProperties: false + description: "v1.41 — one entry of a turn's trail as persisted on `metricSource.activity`: phase, status, round, catalog + label, counts. The model-written title and text are behind `GET + /api/insights/chat/{id}/messages/{messageId}/trail`." + CoachStop: + type: object + properties: + reason: + type: string + enum: + - budget + - time + - cap + - no_progress + rounds: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The rounds the turn ran, the final one included. + required: + - reason + - rounds + additionalProperties: false + description: "v1.41 — why the Coach answered before it was done reading: the token budget, the time budget, the round + cap, or no new data." + CoachAssumption: + type: object + properties: + kind: + type: string + enum: + - metric + - window + - comparison + value: + type: object + properties: + 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 + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." + required: + - labelKey + - label + - value + additionalProperties: false + alternatives: + type: array + items: + type: object + properties: + 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 + comparison: + description: v1.41 — what a comparison is drawn against. + type: string + enum: + - previous_period + - year_ago + - baseline_90d + goal: + description: v1.41 — an active plan or a goal fact, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + anchor: + description: v1.41 — a medication start, an illness episode or a cycle phase, by id. + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + additionalProperties: false + description: "What a choice stands for: catalog tokens and ids, never text." + required: + - labelKey + - label + - value + additionalProperties: false + description: At most 3. + required: + - kind + - value + - alternatives + additionalProperties: false + description: v1.41 — what an answer assumed instead of asking, from a catalog. Each alternative is offered as a + `change_assumption` follow-up. + CoachMemoryNoteMeta: + type: object + properties: + proposal: + type: boolean + description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes + `factId`." + proposalId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + factId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + required: + - proposal + - category + additionalProperties: false + description: v1.41 — the fact a reply saved or proposes, as persisted on `metricSource.memoryNote`. No fact text. + CoachPlanProposalMeta: + type: object + properties: + planId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + metric: + type: string + maxLength: 64 + reviewInDays: + type: integer + minimum: 7 + maximum: 56 + required: + - planId + - metric + - reviewInDays + additionalProperties: false + description: v1.41 — the plan a reply proposes, as persisted on `metricSource.planProposal`. No plan text. CoachUsage: type: object properties: @@ -53028,8 +53761,8 @@ components: properties: ref: type: string - pattern: ^r[1-6]$ - description: "`r1`..`r6`, unique within a message." + pattern: ^r[1-8]$ + description: "`r1`..`r8`, unique within a message." source: type: object properties: @@ -53095,6 +53828,7 @@ components: - type: string enum: - line + - compare - bar - histogram - type: "null" @@ -53111,7 +53845,7 @@ components: pattern: ^[A-Za-z0-9_-]{1,64}$ ref: type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ required: - messageId - ref @@ -53184,6 +53918,38 @@ components: - x - series additionalProperties: false + - type: object + properties: + kind: + type: string + const: compare + mode: + type: string + enum: + - periods + - metrics + x: + type: string + a: + type: string + b: + type: string + axes: + anyOf: + - type: number + const: 1 + - type: number + const: 2 + required: + - kind + - mode + - x + - a + - b + - axes + additionalProperties: false + description: "v1.41 — two series on one chart: two periods overlaid (`periods`, one axis) or two metrics (`metrics`, one + or two axes). `a` and `b` name the value columns, `x` the shared column." - type: object properties: kind: @@ -53257,6 +54023,143 @@ components: 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." + CoachActivity: + type: object + properties: + id: + type: string + pattern: ^a[1-9]\d?$ + description: "`a1`..`a99`, unique within a turn." + phase: + type: string + enum: + - thinking + - memory + - fetch + - digest + - checkpoint + - remember + - plan + - asking + - stop + - answer + status: + type: string + enum: + - running + - done + - empty + - failed + round: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The tool round, from 1. + labelKey: + type: string + description: Closed catalog key (`insights.coach.activity.*`). + label: + type: string + description: Rendered on the server in the request locale from the catalog; never model text. + stepRef: + description: The `step` a `fetch` entry stands for. + type: string + pattern: ^s[1-9]\d{0,2}$ + count: + description: Readings, lookups or entries the server counted. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + durationMs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stop: + description: Set on the `stop` entry. + type: string + enum: + - budget + - time + - cap + - no_progress + title: + description: A screened reasoning title or checkpoint sentence. + type: string + maxLength: 80 + text: + description: A screened reasoning summary for the round. + type: string + maxLength: 400 + required: + - id + - phase + - status + - round + - labelKey + - label + additionalProperties: false + description: v1.41 — one entry of the live trail, sent as `activity` frames that upsert by `id`. `title` and `text` are + model text, screened, and sent only to the account that owns the conversation. + CoachMemoryNote: + type: object + properties: + proposal: + type: boolean + description: "`true`: waiting for the person, answered with `memoryDecision`. `false`: already saved; undo deletes + `factId`." + proposalId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + factId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + fact: + type: string + maxLength: 160 + required: + - proposal + - category + - fact + additionalProperties: false + description: v1.41 — a fact the Coach saved during the turn, or proposes to save (health facts are never saved without a + tap). Owner only. + CoachPlanProposal: + type: object + properties: + planId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + metric: + type: string + maxLength: 64 + reviewInDays: + type: integer + minimum: 7 + maximum: 56 + ifCue: + type: string + thenAction: + type: string + target: + type: string + required: + - planId + - metric + - reviewInDays + - ifCue + - thenAction + additionalProperties: false + description: "v1.41 — a plan the Coach proposes, written as `proposed`. Answer with `planDecision: { messageId, planId, + accept }`. Owner only." CoachMessageResultsResponse: type: object properties: @@ -53289,7 +54192,7 @@ components: properties: ref: type: string - pattern: ^r[1-6]$ + pattern: ^r[1-8]$ withheld: type: string enum: @@ -53302,6 +54205,88 @@ components: - withheld additionalProperties: false description: "One stored table, or the reason it is not served. Absence is explicit: a withheld table names why." + CoachMessageTrailResponse: + type: object + properties: + data: + type: object + properties: + trail: + anyOf: + - $ref: "#/components/schemas/CoachTrail" + - type: "null" + ai: + description: The `coach` capability. While unavailable `trail.entries` is empty. + $ref: "#/components/schemas/AiCapabilityState" + required: + - trail + - ai + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachTrail: + type: object + properties: + entries: + type: array + items: + type: object + properties: + id: + type: string + pattern: ^a[1-9]\d?$ + title: + type: string + maxLength: 80 + text: + type: string + maxLength: 400 + required: + - id + additionalProperties: false + recalled: + type: array + items: + type: string + maxLength: 160 + proposal: + type: object + properties: + proposalId: + type: string + pattern: ^[A-Za-z0-9_-]{1,64}$ + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + fact: + type: string + maxLength: 160 + required: + - proposalId + - category + - fact + additionalProperties: false + required: + - entries + additionalProperties: false + description: v1.41 — the model-written text of a turn's trail and the fact texts it touched. Encrypted at rest and + served only to the account that owns the conversation. CoachConversationDetailResponse: type: object properties: @@ -53744,11 +54729,12 @@ components: type: string enum: - preference - - condition - goal - - constraint - context - description: "App-side closed category: preference | condition | goal | constraint | context." + - condition + - constraint + - medication + description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." text: type: string description: Decrypted fact text. @@ -53757,16 +54743,54 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: 0..100 server-assigned extraction confidence. + source: + type: string + enum: + - user + - coach + - extracted + - pattern + - proposed + description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), + `coach` (saved during a turn), `extracted` (the background extraction; every fact older than + v1.41), `pattern` (the deterministic matcher). `proposed` is never listed." + sourceConversationId: + anyOf: + - type: string + - type: "null" + description: The conversation the fact came out of, when known. + sourceMessageId: + anyOf: + - type: string + - type: "null" + description: v1.41 — the message the fact came out of, when known. + lastUsedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: v1.41 — when the fact last went into a Coach turn. createdAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: v1.41 — when the fact was last written or edited. required: - id - category - text - confidence + - source + - sourceConversationId + - sourceMessageId + - lastUsedAt - createdAt + - updatedAt additionalProperties: false description: The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted. required: @@ -53784,6 +54808,128 @@ components: - data - error additionalProperties: false + CoachFactAlreadyKnown: + type: object + properties: + data: + type: object + properties: + fact: + type: object + properties: + id: + type: string + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." + text: + type: string + description: The fact as stored. + source: + type: string + enum: + - user + - coach + - extracted + - pattern + - proposed + description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), + `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), + `pattern` (the deterministic matcher). `proposed` is never listed." + required: + - id + - category + - text + - source + additionalProperties: false + created: + type: boolean + description: False when the caller's memory already held the same fact (or a proposal for it, now confirmed); the + existing fact is returned. + required: + - fact + - created + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachFactCreated: + type: object + properties: + data: + type: object + properties: + fact: + type: object + properties: + id: + type: string + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." + text: + type: string + description: The fact as stored. + source: + type: string + enum: + - user + - coach + - extracted + - pattern + - proposed + description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), + `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), + `pattern` (the deterministic matcher). `proposed` is never listed." + required: + - id + - category + - text + - source + additionalProperties: false + created: + type: boolean + description: False when the caller's memory already held the same fact (or a proposal for it, now confirmed); the + existing fact is returned. + required: + - fact + - created + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CoachFactsCleared: type: object properties: @@ -53810,6 +54956,67 @@ components: - data - error additionalProperties: false + CoachFactUpdated: + type: object + properties: + data: + type: object + properties: + fact: + type: object + properties: + id: + type: string + category: + type: string + enum: + - preference + - goal + - context + - condition + - constraint + - medication + description: "App-side closed category: preference | goal | context | condition | constraint | medication (v1.41)." + text: + type: string + description: The fact as stored. + source: + type: string + enum: + - user + - coach + - extracted + - pattern + - proposed + description: "v1.41 — where the fact came from: `user` (the remember button, or a proposal the caller confirmed), + `coach` (saved during a turn), `extracted` (the background extraction; every fact older than v1.41), + `pattern` (the deterministic matcher). `proposed` is never listed." + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - category + - text + - source + - updatedAt + additionalProperties: false + required: + - fact + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CoachFactDeleted: type: object properties: @@ -58213,7 +59420,16 @@ components: description: Robust upper edge. latest: type: number - description: Today's latest reading, same units. + description: The latest reading, same units. Today's only when `latestIsToday` is true; otherwise it is from + `latestDate`. + latestDate: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + description: Calendar day of the latest reading in the account's time zone, `YYYY-MM-DD`. + latestIsToday: + type: boolean + description: True when `latestDate` is today in the account's time zone. When false, a client names the reading's date + instead of calling it today's. placement: type: string enum: @@ -58225,10 +59441,19 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Distinct days behind the band, for transparency. + basis: + description: Present when today is still in progress for a metric whose day mean moves with the hour (blood glucose). + `latest` is then today's mean so far and `low` / `high` the usual range for the same hours of the + earlier days, not the whole-day band; a client naming the range says it is the one for this time of + day. Absent for the whole-day comparison. + type: string + const: sameHours required: - low - high - latest + - latestDate + - latestIsToday - placement - sampleDays additionalProperties: false @@ -73649,6 +74874,8 @@ components: $ref: "#/components/schemas/ModuleAccessMap" ai: $ref: "#/components/schemas/AiAccountBlock" + coachReasoning: + $ref: "#/components/schemas/CoachReasoningState" recordSession: anyOf: - $ref: "#/components/schemas/RecordSessionState" @@ -73675,6 +74902,7 @@ components: - accountAccess - moduleAccess - ai + - coachReasoning - recordSession - notificationPrefs - onboarding @@ -73689,7 +74917,9 @@ components: carries none — the two are the same account and nothing is masked. `moduleAccess` says WHY each module is or is not there — the record's own switch, the edge of the grant, or the operator's instance-wide one — beside the `modules` booleans it never contradicts. `ai` (v1.39) is the resolved AI capability map for the same record, - with the same masking; it replaces reading `GET /api/feature-flags`." + with the same masking; it replaces reading `GET /api/feature-flags`. `coachReasoning` (v1.41) is the Coach's + resolved thinking depth: the caller's own preference against the operator's reasoning controls and the record's + provider." AccountAccess: type: object properties: @@ -74182,6 +75412,67 @@ components: - responseTimeoutMs additionalProperties: false description: The account-level companion to the capability map. + CoachReasoningState: + type: object + properties: + level: + type: string + enum: + - off + - low + - medium + - high + description: "The level a Coach turn runs at right now, every cap applied: the person's choice, lowered to the + operator's highest level, to `medium` when the operator pays for the provider, and `off` when the operator + switched reasoning off or the provider cannot reason." + preference: + type: string + enum: + - off + - low + - medium + - high + description: What the person picked (`reasoning` on `PUT /api/auth/me/coach-prefs`), `medium` when they never chose. + Bind the settings field to this, not to `level`. + maxLevel: + type: string + enum: + - off + - low + - medium + - high + description: The highest level the settings may offer. Options above it are shown disabled as limited by the operator. + `off` when the operator switched reasoning off. + available: + type: boolean + description: False when the operator switched reasoning off or the configured provider cannot reason; the settings field + is then locked. + offIsReal: + type: boolean + description: False when the provider cannot switch reasoning off (it thinks at its lowest level instead); label the + lowest option "Minimal" rather than "Off". + source: + type: string + enum: + - user + - job + - admin_off + - admin_cap + - cost_cap + - unsupported + description: "Which layer decided `level`: the person's own choice (`user`), the operator's switch (`admin_off`) or cap + (`admin_cap`), the operator-funded ceiling (`cost_cap`), or a provider that cannot reason (`unsupported`). + `job` never appears here." + required: + - level + - preference + - maxLevel + - available + - offIsReal + - source + additionalProperties: false + description: The Coach's thinking depth for the signed-in person, resolved on the server from their preference, the + operator's reasoning switch and cap, who pays for the provider, and what the provider can do. RecordSessionState: type: object properties: @@ -78175,6 +79466,16 @@ components: description: v1.39.4 — offer up to three follow-up chips under the Coach's latest answer. Absent means on; only `false` switches them off. type: boolean + reasoning: + description: "v1.41 — how hard the Coach thinks before it answers: `off`, `low`, `medium` or `high`. Absent means + `medium`. The operator may switch reasoning off or cap the level; the resolved value is published on `GET + /api/auth/me` as `coachReasoning`." + type: string + enum: + - off + - low + - medium + - high required: - tone - verbosity diff --git a/e2e/coach-activity.spec.ts b/e2e/coach-activity.spec.ts new file mode 100644 index 000000000..259a8b2f6 --- /dev/null +++ b/e2e/coach-activity.spec.ts @@ -0,0 +1,488 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * The live trail of a Coach turn: one quiet status line at the top of the + * answer. + * + * Contracts under test, at 1440 and 390 px, light and dark: + * + * 1. While the turn runs the line says what is happening right now and + * moves with the frames: Thinking… → Fetching blood pressure… → + * Summarising 214 readings… → Writing the answer…. + * 2. A table the turn has read shows as a one-line preview before the + * first token. + * 3. Nothing opens by itself: the trail stays closed (`aria-expanded` + * false) in every state, after the answer and after a reload. + * 4. After the answer the line is one summary ("Thought it through, 1 + * lookup, 7 s"); the preview gives way to the answer's chart. + * 5. A tap opens the trail; the reasoning text of a round sits behind its + * own closed disclosure. + * 6. No horizontal scroll, a 44 px tap target on phones, axe clean. + * + * The chat stream is fed frame by frame from the spec + * (`installLiveCoachStream`), so every live state is observed rather than + * flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each + * state. + */ + +const CONVERSATION_ID = "coach-activity-e2e"; +const QUESTION = "How was my blood pressure this month?"; +const ANSWER = + "Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range."; + +const BP_VALUES: Array<[number, number]> = [ + [128, 84], + [125, 82], + [131, 85], + [122, 80], + [124, 81], + [127, 83], + [121, 79], + [126, 82], + [123, 80], + [126, 81], +]; + +const BP_TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: BP_VALUES.length, + chartKind: "line", + displayed: false, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", + }, + ], + rows: BP_VALUES.map(([sys, dia], i) => [ + new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10), + sys, + dia, + i === 0 ? 205 : 1, + ]), + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, +}; +const BP_SHOWN = { ...BP_TABLE, displayed: true }; +const BP_META = { + ref: "r1", + source: BP_TABLE.source, + shape: BP_TABLE.shape, + titleKey: BP_TABLE.titleKey, + title: BP_TABLE.title, + rowCount: BP_TABLE.rowCount, + chartKind: "line", + displayed: true, +}; + +const STEP = { + id: "s1", + tool: "get_metric_table", + labelKey: "coach.step.readWindow", + label: "Checking: Blood pressure, last 30 days", + domain: "bp", + window: "last30days", + granularity: "day", +}; + +function entry( + id: string, + phase: string, + status: string, + label: string, + extra: Record = {}, +) { + return { + type: "activity", + activity: { + id, + phase, + status, + round: phase === "answer" ? 2 : 1, + labelKey: `insights.coach.activity.${phase}`, + label, + ...extra, + }, + }; +} + +const THINK_TITLE = "Looking at the whole month"; +const THINK_TEXT = + "The month has daily readings; a daily table answers this best."; + +const ACTIVITY_META = [ + { + id: "a1", + phase: "thinking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + durationMs: 2_400, + }, + { + id: "a2", + phase: "fetch", + status: "done", + round: 1, + labelKey: "insights.coach.activity.fetching", + label: "Fetching blood pressure, last 30 days…", + stepRef: "s1", + count: 214, + durationMs: 900, + }, + { + id: "a3", + phase: "digest", + status: "done", + round: 1, + labelKey: "insights.coach.activity.digestDone", + label: "214 readings from 1 area", + count: 214, + durationMs: 400, + }, + { + id: "a4", + phase: "answer", + status: "done", + round: 2, + labelKey: "insights.coach.activity.answer", + label: "Writing the answer…", + durationMs: 3_000, + }, +]; + +const METRIC_SOURCE = { + windows: ["last30days"], + metrics: ["bp"], + counts: { bp: 214 }, + steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }], + activity: ACTIVITY_META, + results: [BP_META], + method: { + entries: [ + { + domain: "bp", + window: "last30days", + granularity: "day", + count: 214, + aggregation: "mean", + }, + ], + text: "Blood pressure, last 30 days, 214 readings, daily averages.", + }, +}; + +function conversation(): StubConversation { + return { + id: CONVERSATION_ID, + title: "Blood pressure this month", + messages: [], + results: { "coach-activity-e2e-a1": [BP_SHOWN] }, + trails: { + "coach-activity-e2e-a1": { + entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }], + }, + }, + }; +} + +const VIEWPORTS = [ + { name: "1440", width: 1440, height: 900 }, + { name: "390", width: 390, height: 844 }, +] as const; + +const SHOTS = process.env.COACH_SCREENSHOTS_DIR; + +async function shot(page: Page, name: string) { + if (!SHOTS) return; + mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: join(SHOTS, `${name}.png`) }); +} + +async function noHorizontalScroll(page: Page) { + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); +} + +test.describe("Coach live trail", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + for (const viewport of VIEWPORTS) { + for (const theme of ["light", "dark"] as const) { + test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "sets its own viewports; one project suffices", + ); + const label = `${viewport.name}-${theme}`; + await page.setViewportSize(viewport); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub = conversation(); + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, theme); + + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION); + await page.locator('[data-slot="coach-input-send"]').click(); + + const turn = page.locator( + '[role="log"] [data-slot="coach-bubble-assistant"]', + ); + const line = turn.locator('[data-slot="coach-turn-steps"]'); + const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]'); + const current = line.locator('[data-slot="coach-turn-steps-active"]'); + + // 1. Before any frame: Thinking…, closed. + await expect(line).toHaveAttribute("data-state", "running"); + await expect(current).toHaveText("Thinking…"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + await feed(page, [entry("a1", "thinking", "running", "Thinking…")]); + await feed(page, [ + entry("a1", "thinking", "running", "Thinking…", { + title: THINK_TITLE, + }), + ]); + await expect(current).toHaveText(THINK_TITLE); + + await feed(page, [ + entry("a1", "thinking", "done", "Thinking…", { + title: THINK_TITLE, + text: THINK_TEXT, + durationMs: 2_400, + }), + { type: "step", step: { ...STEP, status: "running" } }, + entry( + "a2", + "fetch", + "running", + "Fetching blood pressure, last 30 days…", + { + stepRef: "s1", + }, + ), + ]); + await expect(current).toHaveText( + "Fetching blood pressure, last 30 days…", + ); + + // 2. The table it read previews before the first token. + await feed(page, [ + { type: "result", result: BP_TABLE, interim: true }, + { type: "step", step: { ...STEP, status: "done", count: 214 } }, + entry( + "a2", + "fetch", + "done", + "Fetching blood pressure, last 30 days…", + { + stepRef: "s1", + count: 214, + durationMs: 900, + }, + ), + entry("a3", "digest", "running", "Summarising 214 readings…", { + count: 214, + }), + ]); + await expect(current).toHaveText("Summarising 214 readings…"); + const preview = turn.locator( + '[data-slot="coach-interim-result"][data-ref="r1"]', + ); + await expect(preview).toBeVisible(); + await expect(preview).toContainText("Blood pressure by day"); + await expect( + turn.locator('[data-slot="coach-answer-bubble"]'), + ).toHaveCount(0); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await noHorizontalScroll(page); + await shot(page, `${label}-1-running`); + + await feed(page, [ + entry("a3", "digest", "done", "214 readings from 1 area", { + count: 214, + durationMs: 400, + }), + entry("a4", "answer", "running", "Writing the answer…"), + ...tokens(ANSWER), + ]); + await expect(current).toHaveText("Writing the answer…"); + await expect( + turn.locator('[data-slot="coach-answer-bubble"]'), + ).toContainText(ANSWER); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + + // The persisted copy lands with the refetch after `done`. + stub.messages.push( + { id: "coach-activity-e2e-u1", role: "user", content: QUESTION }, + { + id: "coach-activity-e2e-a1", + role: "assistant", + content: ANSWER, + metricSource: METRIC_SOURCE, + }, + ); + await feed(page, [ + entry("a4", "answer", "done", "Writing the answer…", { + durationMs: 3_000, + }), + { type: "provenance", metricSource: METRIC_SOURCE }, + { type: "result", result: BP_SHOWN }, + done(CONVERSATION_ID, "coach-activity-e2e-a1"), + ]); + await end(page); + + // 4. One calm summary, still closed; the preview became the chart. + const bubble = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }); + const settled = bubble.locator('[data-slot="coach-turn-steps"]'); + await expect(settled).toHaveAttribute("data-state", "done"); + await expect( + settled.locator('[data-slot="coach-turn-steps-done"]'), + ).toHaveText("Thought it through, 1 lookup, 7 s"); + const settledToggle = settled.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(settledToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + bubble.locator('[data-slot="coach-interim-result"]'), + ).toHaveCount(0); + await expect( + bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'), + ).toBeVisible(); + await expect( + bubble.locator('[data-slot="coach-result-chart-plot"]'), + ).toBeVisible(); + expect((await posts(page))[0]).toMatchObject({ message: QUESTION }); + await noHorizontalScroll(page); + await shot(page, `${label}-2-settled`); + + // 6. A 44 px tap target on phones. + if (viewport.width < 640) { + const box = await settledToggle.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + + // 5. A tap opens the trail; the round's reasoning stays behind its own + // closed disclosure. + await settledToggle.click(); + await expect(settledToggle).toHaveAttribute("aria-expanded", "true"); + const panel = settled.locator('[data-slot="coach-turn-steps-panel"]'); + await expect(panel).toBeVisible(); + const rows = panel.locator('[data-slot="coach-turn-step"]'); + await expect(rows).toHaveCount(4); + await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking"); + await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch"); + await expect( + panel.locator('[data-slot="coach-method-line"]'), + ).toContainText(METRIC_SOURCE.method.text); + const detail = rows + .nth(0) + .locator('[data-slot="coach-turn-step-detail-toggle"]'); + await expect(detail).toHaveAttribute("aria-expanded", "false"); + await expect(panel).not.toContainText(THINK_TEXT); + await detail.click(); + await expect(panel).toContainText(THINK_TEXT); + await noHorizontalScroll(page); + await shot(page, `${label}-3-open`); + + const axe = await new AxeBuilder({ page }) + .include('[data-slot="coach-bubble-assistant"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + axe.violations.map((v) => ({ + id: v.id, + nodes: v.nodes.map((n) => n.target.join(" ")), + })), + ).toEqual([]); + + // 3. After a reload the trail is closed again, and opening it reads + // the stored titles and texts. + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + const reloaded = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }) + .locator('[data-slot="coach-turn-steps"]'); + await expect(reloaded).toHaveAttribute("data-state", "done"); + await expect( + reloaded.locator('[data-slot="coach-turn-steps-done"]'), + ).toHaveText("Thought it through, 1 lookup, 7 s"); + const reloadedToggle = reloaded.locator( + '[data-slot="coach-turn-steps-toggle"]', + ); + await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false"); + await expect( + reloaded.locator('[data-slot="coach-turn-steps-panel"]'), + ).toHaveCount(0); + await reloadedToggle.click(); + await expect( + reloaded.locator('[data-slot="coach-turn-step"]').nth(0), + ).toContainText(THINK_TITLE); + }); + } + } +}); diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts index fe13504f9..060ab06b2 100644 --- a/e2e/coach-answer-layout.spec.ts +++ b/e2e/coach-answer-layout.spec.ts @@ -18,9 +18,11 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * are not text on the row; the details icon shows them in a tooltip on * keyboard focus (desktop) and on a tap (phone); Enter and Space keep it * open, and a second tap or Escape closes it. - * 2. The follow-up chips sit in the answer's column: their left edge is - * the bubble's, their text is the answer's size, and a chip is at most - * 36 px tall beside a pointer and at least 44 px on a phone. + * 2. The follow-up chips sit in the answer's column, right under the + * answer and above the action row (the row is the last thing in the + * column): their left edge is the bubble's, their text is the + * answer's size, and a chip is at most 36 px tall beside a pointer and + * at least 44 px on a phone. * 3. Under a question, copy and remember share one row; nothing stacks * below it. * 4. Scrolled away from the end, a round button appears; it brings the @@ -311,7 +313,14 @@ test.describe("Coach answer layout", () => { await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2); const chipsBox = await box(chips); expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1); - expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1); + // No empty band between the answer and its replies: the column's own + // gap, whatever the hover state of the row below. + const chipsGap = chipsBox.y - (bubbleBox.y + bubbleBox.height); + expect(chipsGap, "the chips follow the answer").toBeGreaterThanOrEqual(0); + expect(chipsGap, "the chips follow the answer").toBeLessThanOrEqual(12); + expect(rowBox.y, "the action row comes last").toBeGreaterThanOrEqual( + chipsBox.y + chipsBox.height - 1, + ); const answerSize = await bubble.evaluate( (el) => getComputedStyle(el).fontSize, ); diff --git a/e2e/coach-clarification.spec.ts b/e2e/coach-clarification.spec.ts new file mode 100644 index 000000000..a703bfc66 --- /dev/null +++ b/e2e/coach-clarification.spec.ts @@ -0,0 +1,261 @@ +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * A clarifying question is a conversation, not a form. + * + * 1. The question is a normal Coach message; its choices are quiet reply + * pills under it, the assumed one first. There is no card. + * 2. A pill sends its label as the person's own message, with + * `clarification: { messageId, choiceId }`. + * 3. A typed answer works too, with `clarification: { messageId }`. + * 4. The trail above the question stays closed. + */ + +const CONVERSATION_ID = "coach-clarify-e2e"; +const QUESTION = "Is my heart rate okay?"; +const ASKS = + "Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate."; +const ANSWER = + "Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level."; + +const CLARIFICATION = { + kind: "metric", + freeText: true, + assumption: "c1", + choices: [ + { + id: "c1", + labelKey: "insights.coach.metric.resting_hr", + label: "Resting HR", + value: { metric: "resting_hr" }, + }, + { + id: "c2", + labelKey: "insights.coach.metric.walking_hr", + label: "Walking HR", + value: { metric: "walking_hr" }, + }, + ], +}; +const ASK_SOURCE = { + windows: ["last30days"], + metrics: ["resting_hr"], + activity: [ + { + id: "a1", + phase: "thinking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.thinking", + label: "Thinking…", + durationMs: 1_200, + }, + { + id: "a2", + phase: "asking", + status: "done", + round: 1, + labelKey: "insights.coach.activity.asking", + label: "Asking you…", + durationMs: 300, + }, + ], + clarification: CLARIFICATION, +}; + +async function ask( + page: import("@playwright/test").Page, + stub: StubConversation, +) { + await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION); + await page.locator('[data-slot="coach-input-send"]').click(); + const live = page.locator( + '[role="log"] [data-slot="coach-turn-steps-active"]', + ); + await feed(page, [ + { + type: "activity", + activity: { + ...ASK_SOURCE.activity[0], + status: "running", + durationMs: undefined, + }, + }, + { + type: "activity", + activity: { + ...ASK_SOURCE.activity[1], + status: "running", + durationMs: undefined, + }, + }, + ]); + await expect(live).toHaveText("Asking you…"); + stub.messages.push( + { id: "coach-clarify-e2e-u1", role: "user", content: QUESTION }, + { + id: "coach-clarify-e2e-a1", + role: "assistant", + content: ASKS, + metricSource: ASK_SOURCE, + }, + ); + await feed(page, [ + ...tokens(ASKS), + { type: "activity", activity: ASK_SOURCE.activity[1] }, + { type: "provenance", metricSource: ASK_SOURCE }, + { type: "clarification", clarification: CLARIFICATION }, + done(CONVERSATION_ID, "coach-clarify-e2e-a1"), + ]); + await end(page); +} + +test.describe("Coach clarifying question", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("pills in the conversation, the assumed one first, and a tap answers", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "the flow is viewport-independent; the activity spec covers 390 px", + ); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub: StubConversation = { + id: CONVERSATION_ID, + title: "Heart rate", + messages: [], + results: {}, + trails: {}, + }; + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, "light"); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await ask(page, stub); + + const asked = page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ASKS }); + await expect(asked).toHaveCount(1); + // 1. No card anywhere; the pills sit in the answer's own column. + await expect( + page.locator('[data-slot="coach-clarification-card"]'), + ).toHaveCount(0); + const pills = asked.locator('[data-slot="coach-suggested-replies"]'); + await expect(pills).toBeVisible(); + await expect(pills).toHaveAttribute( + "data-message-id", + "coach-clarify-e2e-a1", + ); + const choices = pills.locator("[data-choice-id]"); + await expect(choices).toHaveCount(2); + await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1"); + // 4. The trail stays closed. + await expect( + asked.locator('[data-slot="coach-turn-steps-toggle"]'), + ).toHaveAttribute("aria-expanded", "false"); + + // 2. A tap sends the label as the person's message. + const walking = choices.nth(1); + const label = (await walking.textContent())?.trim() ?? ""; + await walking.click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: label }), + ).toBeVisible(); + await expect(pills).toHaveCount(0); + const sent = await posts(page); + expect(sent[1]).toMatchObject({ + conversationId: CONVERSATION_ID, + message: label, + clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" }, + }); + + stub.messages.push( + { id: "coach-clarify-e2e-u2", role: "user", content: label }, + { + id: "coach-clarify-e2e-a2", + role: "assistant", + content: ANSWER, + metricSource: { windows: ["last30days"], metrics: ["walking_hr"] }, + }, + ); + await feed(page, [ + ...tokens(ANSWER), + done(CONVERSATION_ID, "coach-clarify-e2e-a2"), + ]); + await end(page); + await expect( + page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: ANSWER }), + ).toBeVisible(); + await expect( + page.locator('[data-slot="coach-suggested-replies"]'), + ).toHaveCount(0); + }); + + test("a typed answer answers the question too", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-independent; desktop run suffices", + ); + await page.clock.setFixedTime(FIXED_NOW); + await serveAiBlock(page, aiBlockAvailable()); + const stub: StubConversation = { + id: CONVERSATION_ID, + title: "Heart rate", + messages: [], + results: {}, + trails: {}, + }; + await serveConversation(page, stub); + await installLiveCoachStream(page); + await applyTheme(page, "dark"); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await ask(page, stub); + await expect( + page.locator('[data-slot="coach-suggested-replies"]'), + ).toBeVisible(); + + await page + .locator('[data-slot="coach-input-textarea"]') + .fill("the walking one"); + await page.locator('[data-slot="coach-input-send"]').click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: "the walking one" }), + ).toBeVisible(); + const sent = await posts(page); + expect(sent[1]).toMatchObject({ + message: "the walking one", + clarification: { messageId: "coach-clarify-e2e-a1" }, + }); + expect( + (sent[1].clarification as Record).choiceId, + ).toBeUndefined(); + await end(page); + }); +}); diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts index a5dfc1478..2f7581165 100644 --- a/e2e/coach-dialog.spec.ts +++ b/e2e/coach-dialog.spec.ts @@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * puts the table on the clipboard. * 3. A follow-up chip under the latest reply sends its label with * `followUp: { messageId, id }`. - * 4. A reply that ends in a clarifying question shows the choices card - * and no chips (as the server sends it); a choice sends its label with - * `clarification: { messageId, choiceId }`, and the card leaves once a + * 4. A reply that ends in a clarifying question offers its choices as + * reply pills under it and no chips (as the server sends it); there is + * no card. A choice sends its label with + * `clarification: { messageId, choiceId }`, and the pills leave once a * plain answer is the latest reply. - * 5. axe finds nothing on the answer, the chips and the clarification - * card, in the light and the dark theme. + * 5. axe finds nothing on the answer, the chips and the reply pills, in + * the light and the dark theme. * * The chat POST is a stubbed event stream in the documented frame order, * and the conversation detail it refetches after `done` returns the same @@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) { const result = await new AxeBuilder({ page }) .include('[data-slot="coach-bubble-assistant"]') .include('[data-slot="coach-follow-up-chips"]') - .include('[data-slot="coach-clarification-card"]') + .include('[data-slot="coach-suggested-replies"]') .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); expect( @@ -581,26 +582,31 @@ test.describe("Coach dialog", () => { }); expect(posts[1].clarification).toBeUndefined(); - // 4. The reply asks; the card offers the choices, and the chips of - // the earlier reply are gone. - const card = page.locator('[data-slot="coach-clarification-card"]'); - await expect(card).toBeVisible(); - await expect(card).toHaveAttribute( + // 4. The reply asks; pills under it offer the choices, and the chips + // of the earlier reply are gone. No card. + await expect( + page.locator('[data-slot="coach-clarification-card"]'), + ).toHaveCount(0); + const pills = secondBubble.locator( + '[data-slot="coach-suggested-replies"]', + ); + await expect(pills).toBeVisible(); + await expect(pills).toHaveAttribute( "data-message-id", TURNS[1].assistantId, ); - await expect(card.locator("[data-choice-id]")).toHaveCount(2); + await expect(pills.locator("[data-choice-id]")).toHaveCount(2); await expect(chips).toHaveCount(0); // The earlier result still renders under its own answer. await expect(figure).toBeVisible(); - // 5. axe over the answers, the result in both views and the card. + // 5. axe over the answers, the result in both views and the pills. await expectNoAxeViolations(page, `${theme} chart view with a question`); await figure.locator('[data-slot="coach-result-view-table"]').click(); await expect(table).toBeVisible(); await expectNoAxeViolations(page, `${theme} table view with a question`); - await card.locator('[data-choice-id="c2"]').click(); + await pills.locator('[data-choice-id="c2"]').click(); const thirdBubble = page .locator('[data-slot="coach-bubble-assistant"]') @@ -616,7 +622,7 @@ test.describe("Coach dialog", () => { expect(posts[2].followUp).toBeUndefined(); // A plain answer closes the question and offers no chips. - await expect(card).toHaveCount(0); + await expect(pills).toHaveCount(0); await expect(chips).toHaveCount(0); await expect( thirdBubble.locator('[data-slot="coach-turn-steps"]'), diff --git a/e2e/coach-memory.spec.ts b/e2e/coach-memory.spec.ts new file mode 100644 index 000000000..e9ed1973d --- /dev/null +++ b/e2e/coach-memory.spec.ts @@ -0,0 +1,436 @@ +import type { Page, Route } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { + FIXED_NOW, + done, + end, + feed, + installLiveCoachStream, + posts, + serveConversation, + tokens, + applyTheme, + type StubConversation, +} from "./setup/coach-live-stream"; + +/** + * What the Coach keeps, in the same quiet language as everything else in + * the thread, and the thinking depth in its quick settings. + * + * 1. A fact it saved: one meta line under the answer, "Remembered: …", + * with Undo, which forgets it. + * 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as + * reply pills, a tap answers with `memoryDecision`. + * 3. A plan it proposes: "Take on this plan" / "Not now", with + * `planDecision`. No plan card in the thread. + * 4. The memory list in Settings → Coach: an entry is edited in place. + * 5. The thinking depth: levels above the operator's cap are disabled and + * say so; with reasoning switched off the field is locked with one + * sentence. + */ + +const CONVERSATION_ID = "coach-memory-e2e"; + +function fulfilJson(route: Route, data: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +interface Fact { + id: string; + category: string; + text: string; + confidence: number; + source: string; + createdAt: string; +} + +async function serveFacts(page: Page, facts: Fact[]) { + const calls: Array<{ method: string; path: string; body: unknown }> = []; + await page.route( + /\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const method = request.method(); + calls.push({ + method, + path: url.pathname, + body: method === "GET" ? null : request.postDataJSON(), + }); + const id = url.pathname.split("/").pop() ?? ""; + if (method === "DELETE") { + const at = facts.findIndex((f) => f.id === id); + if (at !== -1) facts.splice(at, 1); + return fulfilJson(route, { deleted: true }); + } + if (method === "PATCH") { + const fact = facts.find((f) => f.id === id); + const next = (request.postDataJSON() as { fact: string }).fact; + if (fact) fact.text = next; + return fulfilJson(route, { fact }); + } + return fulfilJson(route, { facts }); + }, + ); + return calls; +} + +function stub(): StubConversation { + return { + id: CONVERSATION_ID, + title: "Goals", + messages: [], + results: {}, + trails: {}, + }; +} + +async function send(page: Page, text: string) { + await page.locator('[data-slot="coach-input-textarea"]').fill(text); + await page.locator('[data-slot="coach-input-send"]').click(); +} + +async function answer( + page: Page, + conversation: StubConversation, + turn: { + userId: string; + userText: string; + id: string; + text: string; + metricSource: Record; + frames: unknown[]; + providerType?: string; + }, +) { + conversation.messages.push( + { id: turn.userId, role: "user", content: turn.userText }, + { + id: turn.id, + role: "assistant", + content: turn.text, + metricSource: turn.metricSource, + providerType: turn.providerType, + }, + ); + await feed(page, [ + ...tokens(turn.text), + { type: "provenance", metricSource: turn.metricSource }, + ...turn.frames, + done(CONVERSATION_ID, turn.id), + ]); + await end(page); + await expect( + page + .locator('[data-slot="coach-bubble-assistant"]') + .filter({ hasText: turn.text }), + ).toBeVisible(); +} + +test.describe("Coach memory and plans", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-independent; desktop run suffices", + ); + await page.clock.setFixedTime(FIXED_NOW); + await applyTheme(page, "light"); + }); + + test("a saved fact is one quiet line, and Undo forgets it", async ({ + page, + }) => { + await serveAiBlock(page, aiBlockAvailable()); + const facts: Fact[] = [ + { + id: "fact-1", + category: "goal", + text: "You want to reach 75 kg by December", + confidence: 1, + source: "coach", + createdAt: "2026-10-06T09:00:00.000Z", + }, + ]; + const calls = await serveFacts(page, facts); + const conversation = stub(); + await serveConversation(page, conversation); + await installLiveCoachStream(page); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + await send(page, "I want to reach 75 kg by December."); + await answer(page, conversation, { + userId: "coach-memory-e2e-u1", + userText: "I want to reach 75 kg by December.", + id: "coach-memory-e2e-a1", + text: "That is about 3 kg from where you are now, a steady pace over ten weeks.", + metricSource: { + windows: [], + metrics: ["weight"], + memoryNote: { proposal: false, factId: "fact-1", category: "goal" }, + }, + frames: [ + { + type: "memoryNote", + note: { + proposal: false, + factId: "fact-1", + category: "goal", + fact: "You want to reach 75 kg by December", + }, + }, + ], + }); + + const note = page.locator('[data-slot="coach-memory-note"]'); + await expect(note).toHaveText( + /Remembered: You want to reach 75 kg by December\.\s*Undo/, + ); + // No card, no accent: a meta line. + await expect(note).toHaveClass(/text-muted-foreground/); + await note.locator('[data-slot="coach-memory-note-undo"]').click(); + await expect(note).toHaveCount(0); + expect( + calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")), + ).toBe(true); + }); + + test("a proposed fact and a proposed plan are answered with reply pills", async ({ + page, + }) => { + await serveAiBlock(page, aiBlockAvailable()); + await serveFacts(page, []); + const conversation = stub(); + await serveConversation(page, conversation); + await installLiveCoachStream(page); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + + // A health fact is proposed, never saved by itself. + await send(page, "I started taking ramipril in May."); + await answer(page, conversation, { + userId: "coach-memory-e2e-u1", + userText: "I started taking ramipril in May.", + id: "coach-memory-e2e-a1", + text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?", + metricSource: { + windows: [], + metrics: ["bp"], + memoryNote: { + proposal: true, + proposalId: "p1", + category: "medication", + }, + }, + frames: [ + { + type: "memoryNote", + note: { + proposal: true, + proposalId: "p1", + category: "medication", + fact: "Takes ramipril since May", + }, + }, + ], + }); + await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount( + 0, + ); + const pills = page.locator('[data-slot="coach-suggested-replies"]'); + await expect(pills.locator("[data-reply-id]")).toHaveText([ + "Yes, remember it", + "No", + ]); + await pills.locator('[data-reply-id="memory-accept"]').click(); + await expect( + page + .locator('[data-slot="coach-bubble-user"]') + .filter({ hasText: "Yes, remember it" }), + ).toBeVisible(); + expect((await posts(page))[1]).toMatchObject({ + message: "Yes, remember it", + memoryDecision: { + messageId: "coach-memory-e2e-a1", + proposalId: "p1", + accept: true, + }, + }); + await answer(page, conversation, { + userId: "coach-memory-e2e-u2", + userText: "Yes, remember it", + id: "coach-memory-e2e-a2", + text: "Got it, I'll remember.", + providerType: "decision", + metricSource: { windows: [], metrics: [] }, + frames: [], + }); + await expect(pills).toHaveCount(0); + + // A plan is one sentence in the answer, then two pills. No card. + await send(page, "How do I stop snacking late?"); + await answer(page, conversation, { + userId: "coach-memory-e2e-u3", + userText: "How do I stop snacking late?", + id: "coach-memory-e2e-a3", + text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.", + metricSource: { + windows: [], + metrics: [], + planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 }, + }, + frames: [ + { + type: "planProposal", + proposal: { + planId: "plan-1", + metric: "WEIGHT", + reviewInDays: 14, + ifCue: "When you finish dinner", + thenAction: "brush your teeth right away", + }, + }, + ], + }); + await expect( + page.locator('[data-slot="coach-plan-proposal-card"]'), + ).toHaveCount(0); + await expect(pills.locator("[data-reply-id]")).toHaveText([ + "Take on this plan", + "Not now", + ]); + await pills.locator('[data-reply-id="plan-decline"]').click(); + expect((await posts(page))[3]).toMatchObject({ + message: "Not now", + planDecision: { + messageId: "coach-memory-e2e-a3", + planId: "plan-1", + accept: false, + }, + }); + await end(page); + }); + + test("an entry in the memory list is edited in place", async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + const calls = await serveFacts(page, [ + { + id: "fact-1", + category: "goal", + text: "Wants to reach 80 kg by autumn", + confidence: 1, + source: "user", + createdAt: "2026-10-01T09:00:00.000Z", + }, + { + id: "fact-2", + category: "preference", + text: "Prefers morning workouts", + confidence: 0.8, + source: "coach", + createdAt: "2026-10-02T09:00:00.000Z", + }, + ]); + await page.goto("/settings/coach#coach-memory", { + waitUntil: "domcontentloaded", + }); + const card = page.locator("#coach-memory"); + const rows = card.locator('[data-testid="settings-coach-memory-fact"]'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText("from you"); + await expect(rows.nth(1)).toContainText("from the Coach"); + + await rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit"]') + .click(); + const input = rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit-input"]'); + await input.fill("Wants to reach 78 kg by December"); + await rows + .nth(0) + .locator('[data-slot="settings-coach-memory-edit-save"]') + .click(); + await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December"); + expect( + calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1")) + ?.body, + ).toEqual({ fact: "Wants to reach 78 kg by December" }); + }); + + for (const scenario of [ + { + name: "capped at Medium", + block: { + level: "medium", + preference: "medium", + maxLevel: "medium", + available: true, + offIsReal: true, + source: "admin_cap", + }, + }, + { + name: "switched off by the operator", + block: { + level: "off", + preference: "medium", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", + }, + }, + ]) { + test(`thinking depth in the quick settings, ${scenario.name}`, async ({ + page, + }) => { + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { + data: Record | null; + }; + if (body.data) { + body.data.ai = aiBlockAvailable(); + body.data.coachReasoning = scenario.block; + } + await route.fulfill({ response, json: body }); + }); + await serveFacts(page, []); + await serveConversation(page, stub()); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); + await page.locator('[data-slot="coach-settings"]').first().click(); + const select = page.locator('[data-slot="coach-reasoning-select"]'); + await expect(select).toBeVisible(); + await expect(select.locator("option")).toHaveText([ + "Off", + "Low", + "Medium", + scenario.block.available ? "High (limited by the admin)" : "High", + ]); + const field = page.locator('[data-slot="coach-reasoning-field"]'); + if (scenario.block.available) { + await expect(select).toBeEnabled(); + await expect(select.locator('option[value="high"]')).toBeDisabled(); + await expect(field).toContainText("a little more waiting"); + } else { + await expect(select).toBeDisabled(); + await expect(field).toContainText( + "The admin has turned deeper thinking off.", + ); + } + // The memory link names how much the Coach keeps. + await expect( + page.locator('[data-slot="coach-settings-memory-link"]'), + ).toHaveText("Memory (0)"); + }); + } +}); diff --git a/e2e/setup/coach-live-stream.ts b/e2e/setup/coach-live-stream.ts new file mode 100644 index 000000000..e82e97954 --- /dev/null +++ b/e2e/setup/coach-live-stream.ts @@ -0,0 +1,220 @@ +import type { Page, Request, Route } from "@playwright/test"; + +/** + * A Coach chat stream the spec drives frame by frame. + * + * `route.fulfill` hands the browser the whole event stream at once, so the + * live states of a turn (the status line moving from "Thinking…" to + * "Fetching…" to "Summarising…", an interim table before the first token) + * would flash by unobserved. This replaces `fetch` in the page for the chat + * POST only: the response body is a stream the spec feeds with + * `feed(page, frames)` and closes with `end(page)`. Every other request goes + * through untouched, so `page.route` mocks keep working. + * + * The POST bodies are recorded in the page (`posts(page)`). + */ +export async function installLiveCoachStream(page: Page): Promise { + await page.addInitScript(() => { + const w = window as unknown as { + __coachPosts: unknown[]; + __coachFeed: (frames: unknown[]) => void; + __coachEnd: () => void; + }; + const original = window.fetch.bind(window); + let controller: ReadableStreamDefaultController | null = null; + const encoder = new TextEncoder(); + w.__coachPosts = []; + w.__coachFeed = (frames) => { + for (const frame of frames) { + controller?.enqueue( + encoder.encode(`data: ${JSON.stringify(frame)}\n\n`), + ); + } + }; + w.__coachEnd = () => { + controller?.close(); + controller = null; + }; + window.fetch = async (input, init) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const method = ( + init?.method ?? (input instanceof Request ? input.method : "GET") + ).toUpperCase(); + if ( + method === "POST" && + new URL(url, window.location.href).pathname === "/api/insights/chat" + ) { + w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}"))); + const body = new ReadableStream({ + start(c) { + controller = c; + }, + }); + init?.signal?.addEventListener("abort", () => { + try { + controller?.error(new DOMException("aborted", "AbortError")); + } catch { + // already closed + } + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return original(input, init); + }; + }); +} + +export async function feed(page: Page, frames: unknown[]): Promise { + await page.evaluate((f) => { + (window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed( + f, + ); + }, frames); +} + +export async function end(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as { __coachEnd: () => void }).__coachEnd(); + }); +} + +export async function posts( + page: Page, +): Promise>> { + return page.evaluate( + () => + (window as unknown as { __coachPosts: Array> }) + .__coachPosts, + ); +} + +export function tokens(text: string) { + return text.split(/(?<= )/).map((token) => ({ type: "token", token })); +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +export interface StoredMessage { + id: string; + role: "user" | "assistant"; + content: string; + metricSource?: Record | null; + providerType?: string | null; +} + +/** + * The conversation as the stubbed server holds it. The spec pushes the + * persisted turn before it sends `done`, so the refetch after `done` finds + * the copy that replaces the streamed reply. + */ +export interface StubConversation { + id: string; + title: string; + messages: StoredMessage[]; + /** `…/messages/{id}/results`, by message id. */ + results: Record; + /** `…/messages/{id}/trail`, by message id. */ + trails: Record; +} + +export async function serveConversation( + page: Page, + conversation: StubConversation, +): Promise { + const at = (index: number) => + new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString(); + const summary = () => ({ + id: conversation.id, + title: conversation.title, + createdAt: at(0), + updatedAt: at(conversation.messages.length), + messageCount: conversation.messages.length, + fenced: false, + attachments: [], + documentTitle: null, + }); + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route: Route, request: Request) => { + const url = new URL(request.url()); + const base = `/api/insights/chat/${conversation.id}`; + const message = url.pathname.match( + /\/messages\/([^/]+)\/(results|trail)$/, + ); + if (message) { + const [, id, kind] = message; + return kind === "results" + ? fulfilJson(route, { results: conversation.results[id] ?? [] }) + : fulfilJson(route, { + trail: conversation.trails[id] ?? null, + ai: { available: true, reason: null }, + }); + } + if (url.pathname === base) { + return fulfilJson(route, { + ...summary(), + attachmentCount: 0, + summary: null, + messages: conversation.messages.map((m, index) => ({ + id: m.id, + role: m.role, + content: m.content, + createdAt: at(index + 1), + metricSource: m.metricSource ?? null, + providerType: + m.role === "assistant" ? (m.providerType ?? "mock") : null, + promptVersion: null, + tokensUsed: m.role === "assistant" ? 420 : null, + model: m.role === "assistant" ? "mock" : null, + })), + }); + } + if (url.pathname === "/api/insights/chat" && request.method() === "GET") { + return fulfilJson(route, { + conversations: conversation.messages.length > 0 ? [summary()] : [], + nextCursor: null, + }); + } + return route.fallback(); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +export function done(conversationId: string, messageId: string) { + return { + type: "done", + conversationId, + messageId, + usage: { totalTokens: 420 }, + }; +} + +export async function applyTheme(page: Page, theme: "light" | "dark") { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); +} + +/** Midday, so no "today / yesterday" boundary moves a label. */ +export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00"); diff --git a/messages/de.json b/messages/de.json index cc16bbd08..90baf90f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "Merken", - "done": "Gemerkt — in deiner Selbstauskunft gespeichert", - "duplicate": "Schon in deiner Selbstauskunft enthalten", - "failed": "Merken fehlgeschlagen. Bitte erneut versuchen." + "failed": "Merken fehlgeschlagen. Bitte erneut versuchen.", + "alreadyKept": "Das weiß der Coach schon." }, "guided": { "offerTitle": "Der Coach hat {count} Rückfragen an dich", @@ -3019,7 +3018,7 @@ "hideConversations": "Unterhaltungen ausblenden", "heroGreeting": "Frage mich etwas zu deinen Daten", "heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.", - "thinkingElapsed": "{seconds}s", + "thinkingElapsed": "{seconds} s", "thinkingDuration": "Nachgedacht für {seconds}s", "thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat", "thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.", @@ -3030,6 +3029,18 @@ "baselineWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darin.", "baselineAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darüber.", "baselineBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; heute liegt {value} darunter.", + "sameHoursWithin": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also im Bereich.", + "sameHoursAbove": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darüber.", + "sameHoursBelow": "Bis zu dieser Tageszeit liegst du sonst bei {low}–{high} {unit}; heute bisher {value}, also darunter.", + "steadyWithin": "Deine Werte liegen meist bei {low} {unit}; heute passt {value} dazu.", + "steadyAbove": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darüber.", + "steadyBelow": "Deine Werte liegen meist bei {low} {unit}; heute liegt {value} darunter.", + "latestWithin": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darin.", + "latestAbove": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.", + "latestBelow": "Dein üblicher Bereich liegt bei {low}–{high} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.", + "steadyLatestWithin": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} genau dort.", + "steadyLatestAbove": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.", + "steadyLatestBelow": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.", "insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "{count} Bereiche angesehen", "info": "Details zur Antwort", "infoModel": "Modell: {model}" - } + }, + "activity": { + "thinking": "Denkt nach…", + "fetching": "Holt {domain}, {window}…", + "digestOne": "Fasst {count} Wert zusammen…", + "digestFew": "Fasst {count} Werte zusammen…", + "digestOther": "Fasst {count} Werte zusammen…", + "digestDoneOne": "{count} Wert aus {areas}", + "digestDoneFew": "{count} Werte aus {areas}", + "digestDoneOther": "{count} Werte aus {areas}", + "areasOne": "{count} Bereich", + "areasFew": "{count} Bereichen", + "areasOther": "{count} Bereichen", + "memoryOne": "Erinnert sich an {count} Eintrag", + "memoryFew": "Erinnert sich an {count} Einträge", + "memoryOther": "Erinnert sich an {count} Einträge", + "remember": "Merkt sich etwas…", + "plan": "Schlägt einen Plan vor…", + "asking": "Fragt nach…", + "answer": "Schreibt die Antwort…", + "summaryOne": "Nachgedacht, {lookups} Abfrage, {seconds} s", + "summaryFew": "Nachgedacht, {lookups} Abfragen, {seconds} s", + "summaryOther": "Nachgedacht, {lookups} Abfragen, {seconds} s", + "summaryNoLookups": "Nachgedacht, {seconds} s", + "stop": { + "budget": "Budget erreicht, antwortet mit dem, was da ist", + "time": "Zeit erreicht, antwortet mit dem, was da ist", + "cap": "Viele Schritte, fasst jetzt zusammen", + "noProgress": "Keine neuen Daten mehr, fasst zusammen" + } + }, + "memory": { + "saved": "Gemerkt: {fact}", + "undo": "Rückgängig", + "accept": "Ja, merken", + "decline": "Nein", + "confirmed": "Gemerkt.", + "declined": "Alles klar, dann nicht." + }, + "plan": { + "accept": "Plan übernehmen", + "decline": "Nicht jetzt", + "confirmed": "Ist notiert. Ich schaue in {days} Tagen mit dir drauf.", + "declined": "Alles klar, dann nicht jetzt." + }, + "assumption": { + "line": "Angenommen: {value}", + "change": "Ändern" + }, + "suggestedReplies": { + "groupLabel": "Antwortvorschläge" + }, + "interim": { + "labelOne": "{title}, {window}, {count} Wert", + "labelFew": "{title}, {window}, {count} Werte", + "labelOther": "{title}, {window}, {count} Werte" + }, + "chart": { + "compare": { + "periods": "{a} gegen {b}" + } + }, + "reasoning": { + "label": "Denktiefe", + "hint": "Gründlicher denken heißt genauere Antworten und etwas mehr Wartezeit.", + "off": "Aus", + "minimal": "Minimal", + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "capped": "(vom Admin begrenzt)", + "disabled": "Der Admin hat gründliches Denken abgeschaltet." + }, + "memoryLink": "Gedächtnis ({count})" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -5992,7 +6076,6 @@ "categoryGoal": "Ziele", "categoryConstraint": "Einschränkungen", "categoryContext": "Kontext", - "forget": "Vergessen", "forgetAria": "Diese Angabe vergessen", "forgetTitle": "Diese Notiz vergessen?", "forgetBody": "Der Coach nutzt sie ab der nächsten Nachricht nicht mehr. Eine vergessene Notiz lässt sich nicht wiederherstellen.", @@ -6008,7 +6091,8 @@ "forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.", "clearedToast": "{count} gespeicherte Angaben vergessen.", "clearError": "Konnte die gespeicherten Angaben nicht löschen. Bitte erneut versuchen.", - "detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte." + "detail": "Er nutzt sie, damit seine Antworten relevant bleiben. Entferne alles, was er besser vergessen sollte.", + "categoryMedication": "Medikamente" }, "coachReminders": { "title": "Deine Erinnerungen", @@ -6124,7 +6208,8 @@ "low": "Niedrig", "medium": "Mittel", "high": "Hoch" - } + }, + "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "Coach ist aktiviert.", "savedOff": "Coach ist ausgeblendet.", "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." + }, + "memory": { + "edit": "Bearbeiten", + "source": { + "user": "von dir", + "coach": "vom Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7250,11 @@ "documentAi": { "title": "Dokumente lesen", "description": "KI-Lesen gespeicherter Dokumente, Scans von Laborbefunden und das Erkennen von Medikamenten aus Text. Der größte Anteil der Daten, die den Server verlassen." + }, + "reasoning": { + "enabled": "Gründliches Denken erlauben", + "max": "Höchste Denktiefe", + "description": "Modelle denken vor der Antwort nach: im Coach, im Tagesbriefing und in den Zusammenfassungen. Genauer, braucht mehr Tokens." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "Siehe auch: {metric}", "continue": "Weitersuchen", "settingLabel": "Anschlussfragen vorschlagen", - "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage." + "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage.", + "changeAssumption": "Stattdessen: {value}" }, "clarify": { "cardLabel": "Der Coach hat eine Rückfrage", @@ -9946,6 +10044,12 @@ "last90days": "Letzte 90 Tage", "lastYear": "Letzte 12 Monate", "allTime": "Gesamter Zeitraum" + }, + "comparison": { + "baseline90d": "Mit meinen üblichen 90 Tagen vergleichen" + }, + "anchor": { + "illness": "Seit der Erkrankung vom {date}" } }, "reuse": { diff --git a/messages/en.json b/messages/en.json index 9fd033567..cb3e738ea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "Remember", - "done": "Saved to your self-context", - "duplicate": "Already part of your self-context", - "failed": "Could not save. Please try again." + "failed": "Could not save. Please try again.", + "alreadyKept": "The Coach already remembers this." }, "guided": { "offerTitle": "The Coach has {count} follow-up questions for you", @@ -3019,7 +3018,7 @@ "hideConversations": "Hide conversations", "heroGreeting": "Ask me anything about your data", "heroSubline": "Ask about your trends, medications, or readings.", - "thinkingElapsed": "{seconds}s", + "thinkingElapsed": "{seconds} s", "thinkingDuration": "Thought for {seconds}s", "thinkingExpandAria": "Show what the Coach considered", "thinkingDetail": "Answered from your health snapshot.", @@ -3030,6 +3029,18 @@ "baselineWithin": "Your usual range is {low}–{high} {unit}; today's {value} sits within it.", "baselineAbove": "Your usual range is {low}–{high} {unit}; today's {value} sits above it.", "baselineBelow": "Your usual range is {low}–{high} {unit}; today's {value} sits below it.", + "sameHoursWithin": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is within that.", + "sameHoursAbove": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is above that.", + "sameHoursBelow": "By this time of day you usually sit at {low}–{high} {unit}; today so far, {value} is below that.", + "steadyWithin": "Your readings usually sit at {low} {unit}; today's {value} matches.", + "steadyAbove": "Your readings usually sit at {low} {unit}; today's {value} is above that.", + "steadyBelow": "Your readings usually sit at {low} {unit}; today's {value} is below that.", + "latestWithin": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, within it.", + "latestAbove": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, above it.", + "latestBelow": "Your usual range is {low}–{high} {unit}; your latest reading, {date}, was {value}, below it.", + "steadyLatestWithin": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value} and matches.", + "steadyLatestAbove": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, above that.", + "steadyLatestBelow": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, below that.", "insufficient": "Still learning your usual range — keep logging for a few more days.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "Looked at {count} areas", "info": "Answer details", "infoModel": "Model: {model}" - } + }, + "activity": { + "thinking": "Thinking…", + "fetching": "Fetching {domain}, {window}…", + "digestOne": "Summarising {count} reading…", + "digestFew": "Summarising {count} readings…", + "digestOther": "Summarising {count} readings…", + "digestDoneOne": "{count} reading from {areas}", + "digestDoneFew": "{count} readings from {areas}", + "digestDoneOther": "{count} readings from {areas}", + "areasOne": "{count} area", + "areasFew": "{count} areas", + "areasOther": "{count} areas", + "memoryOne": "Recalls {count} thing you told it", + "memoryFew": "Recalls {count} things you told it", + "memoryOther": "Recalls {count} things you told it", + "remember": "Remembering something…", + "plan": "Drafting a plan…", + "asking": "Asking you…", + "answer": "Writing the answer…", + "summaryOne": "Thought it through, {lookups} lookup, {seconds} s", + "summaryFew": "Thought it through, {lookups} lookups, {seconds} s", + "summaryOther": "Thought it through, {lookups} lookups, {seconds} s", + "summaryNoLookups": "Thought it through, {seconds} s", + "stop": { + "budget": "Budget reached, answering with what it has", + "time": "Time limit reached, answering with what it has", + "cap": "Many steps, wrapping up", + "noProgress": "No new data, wrapping up" + } + }, + "memory": { + "saved": "Remembered: {fact}", + "undo": "Undo", + "accept": "Yes, remember it", + "decline": "No", + "confirmed": "Got it, I'll remember.", + "declined": "Okay, I won't." + }, + "plan": { + "accept": "Take on this plan", + "decline": "Not now", + "confirmed": "Noted. I'll check in with you in {days} days.", + "declined": "Okay, not now." + }, + "assumption": { + "line": "Assumed: {value}", + "change": "Change" + }, + "suggestedReplies": { + "groupLabel": "Suggested replies" + }, + "interim": { + "labelOne": "{title}, {window}, {count} reading", + "labelFew": "{title}, {window}, {count} readings", + "labelOther": "{title}, {window}, {count} readings" + }, + "chart": { + "compare": { + "periods": "{a} vs {b}" + } + }, + "reasoning": { + "label": "Thinking depth", + "hint": "Deeper thinking means more precise answers and a little more waiting.", + "off": "Off", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "capped": "(limited by the admin)", + "disabled": "The admin has turned deeper thinking off." + }, + "memoryLink": "Memory ({count})" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -5992,7 +6076,6 @@ "categoryGoal": "Goals", "categoryConstraint": "Constraints", "categoryContext": "Context", - "forget": "Forget", "forgetAria": "Forget this fact", "forgetTitle": "Forget this fact?", "forgetBody": "The Coach stops using it from the next message on. There is no way to bring a forgotten fact back.", @@ -6008,7 +6091,8 @@ "forgotError": "Could not forget that fact. Please try again.", "clearedToast": "Forgot {count} stored facts.", "clearError": "Could not clear the stored facts. Please try again.", - "detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot." + "detail": "It uses these to keep its replies relevant. Remove anything you would rather it forgot.", + "categoryMedication": "Medications" }, "coachReminders": { "title": "Your reminders", @@ -6124,7 +6208,8 @@ "low": "Low", "medium": "Medium", "high": "High" - } + }, + "coachNote": "In the Coach, the thinking depth from the Coach settings applies." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "Coach activated.", "savedOff": "Coach hidden.", "saveError": "Could not save the change. Please try again." + }, + "memory": { + "edit": "Edit", + "source": { + "user": "from you", + "coach": "from the Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7250,11 @@ "documentAi": { "title": "Reading documents", "description": "AI reading of stored documents, lab report scans and medication extraction from text. The largest share of data leaving the server." + }, + "reasoning": { + "enabled": "Allow deeper thinking", + "max": "Highest thinking depth", + "description": "Models think before they answer: the Coach, the daily briefing and the period summaries. More precise, uses more tokens." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "See also: {metric}", "continue": "Keep looking", "settingLabel": "Suggest follow-up questions", - "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer." + "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer.", + "changeAssumption": "Instead: {value}" }, "clarify": { "cardLabel": "The Coach has a question", @@ -9946,6 +10044,12 @@ "last90days": "Last 90 days", "lastYear": "Last 12 months", "allTime": "All time" + }, + "comparison": { + "baseline90d": "Compare with my usual 90 days" + }, + "anchor": { + "illness": "Since the illness of {date}" } }, "reuse": { diff --git a/messages/es.json b/messages/es.json index 47605ac34..531bcc740 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "Recordar", - "done": "Guardado en tu información personal", - "duplicate": "Ya está en tu información personal", - "failed": "No se pudo guardar. Inténtalo de nuevo." + "failed": "No se pudo guardar. Inténtalo de nuevo.", + "alreadyKept": "El Coach ya lo recuerda." }, "guided": { "offerTitle": "El Coach tiene {count} preguntas para ti", @@ -3019,7 +3018,7 @@ "hideConversations": "Ocultar conversaciones", "heroGreeting": "Pregúntame lo que quieras sobre tus datos", "heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.", - "thinkingElapsed": "{seconds}s", + "thinkingElapsed": "{seconds} s", "thinkingDuration": "Pensó durante {seconds}s", "thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta", "thinkingDetail": "Respondido a partir de tu resumen de salud.", @@ -3030,6 +3029,18 @@ "baselineWithin": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa dentro de él.", "baselineAbove": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por encima.", "baselineBelow": "Tu rango habitual es {low}–{high} {unit}; hoy {value} se sitúa por debajo.", + "sameHoursWithin": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está dentro.", + "sameHoursAbove": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por encima.", + "sameHoursBelow": "Hasta esta hora del día sueles estar en {low}–{high} {unit}; hoy, por ahora, {value} está por debajo.", + "steadyWithin": "Tus valores suelen estar en {low} {unit}; hoy {value} coincide.", + "steadyAbove": "Tus valores suelen estar en {low} {unit}; hoy {value} está por encima.", + "steadyBelow": "Tus valores suelen estar en {low} {unit}; hoy {value} está por debajo.", + "latestWithin": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, dentro de él.", + "latestAbove": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por encima.", + "latestBelow": "Tu rango habitual es {low}–{high} {unit}; tu última medición, del {date}, fue {value}, por debajo.", + "steadyLatestWithin": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value} y coincide.", + "steadyLatestAbove": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por encima.", + "steadyLatestBelow": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por debajo.", "insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "{count} áreas consultadas", "info": "Detalles de la respuesta", "infoModel": "Modelo: {model}" - } + }, + "activity": { + "thinking": "Pensando…", + "fetching": "Consultando {domain}, {window}…", + "digestOne": "Resumiendo {count} valor…", + "digestFew": "Resumiendo {count} valores…", + "digestOther": "Resumiendo {count} valores…", + "digestDoneOne": "{count} valor de {areas}", + "digestDoneFew": "{count} valores de {areas}", + "digestDoneOther": "{count} valores de {areas}", + "areasOne": "{count} área", + "areasFew": "{count} áreas", + "areasOther": "{count} áreas", + "memoryOne": "Recuerda {count} cosa que le contaste", + "memoryFew": "Recuerda {count} cosas que le contaste", + "memoryOther": "Recuerda {count} cosas que le contaste", + "remember": "Recordando algo…", + "plan": "Preparando un plan…", + "asking": "Te pregunta…", + "answer": "Escribiendo la respuesta…", + "summaryOne": "Lo ha pensado, {lookups} consulta, {seconds} s", + "summaryFew": "Lo ha pensado, {lookups} consultas, {seconds} s", + "summaryOther": "Lo ha pensado, {lookups} consultas, {seconds} s", + "summaryNoLookups": "Lo ha pensado, {seconds} s", + "stop": { + "budget": "Presupuesto alcanzado, responde con lo que tiene", + "time": "Tiempo agotado, responde con lo que tiene", + "cap": "Muchos pasos, ahora lo resume", + "noProgress": "No hay datos nuevos, lo resume" + } + }, + "memory": { + "saved": "Recordado: {fact}", + "undo": "Deshacer", + "accept": "Sí, recuérdalo", + "decline": "No", + "confirmed": "Entendido, lo recordaré.", + "declined": "De acuerdo, no lo haré." + }, + "plan": { + "accept": "Adoptar este plan", + "decline": "Ahora no", + "confirmed": "Anotado. Lo revisaremos juntos dentro de {days} días.", + "declined": "De acuerdo, ahora no." + }, + "assumption": { + "line": "Supuesto: {value}", + "change": "Cambiar" + }, + "suggestedReplies": { + "groupLabel": "Respuestas sugeridas" + }, + "interim": { + "labelOne": "{title}, {window}, {count} valor", + "labelFew": "{title}, {window}, {count} valores", + "labelOther": "{title}, {window}, {count} valores" + }, + "chart": { + "compare": { + "periods": "{a} frente a {b}" + } + }, + "reasoning": { + "label": "Profundidad de razonamiento", + "hint": "Pensar más a fondo significa respuestas más precisas y un poco más de espera.", + "off": "Desactivado", + "minimal": "Mínimo", + "low": "Bajo", + "medium": "Medio", + "high": "Alto", + "capped": "(limitado por el administrador)", + "disabled": "El administrador ha desactivado el razonamiento profundo." + }, + "memoryLink": "Memoria ({count})" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -5992,7 +6076,6 @@ "categoryGoal": "Objetivos", "categoryConstraint": "Restricciones", "categoryContext": "Contexto", - "forget": "Olvidar", "forgetAria": "Olvidar este dato", "forgetTitle": "¿Olvidar este dato?", "forgetBody": "El Coach dejará de usarlo a partir del próximo mensaje. Un dato olvidado no se puede recuperar.", @@ -6008,7 +6091,8 @@ "forgotError": "No se pudo olvidar ese dato. Inténtalo de nuevo.", "clearedToast": "Se olvidaron {count} datos guardados.", "clearError": "No se pudieron borrar los datos guardados. Inténtalo de nuevo.", - "detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide." + "detail": "Los usa para que sus respuestas sigan siendo relevantes. Elimina lo que prefieras que olvide.", + "categoryMedication": "Medicamentos" }, "coachReminders": { "title": "Tus recordatorios", @@ -6124,7 +6208,8 @@ "low": "Bajo", "medium": "Medio", "high": "Alto" - } + }, + "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "Coach activado.", "savedOff": "Coach oculto.", "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." + }, + "memory": { + "edit": "Editar", + "source": { + "user": "de ti", + "coach": "del Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7250,11 @@ "documentAi": { "title": "Lectura de documentos", "description": "Lectura con IA de documentos guardados, escaneo de informes de laboratorio y extracción de medicamentos a partir de texto. La mayor parte de los datos que salen del servidor." + }, + "reasoning": { + "enabled": "Permitir razonamiento profundo", + "max": "Profundidad máxima de razonamiento", + "description": "Los modelos piensan antes de responder: el Coach, el resumen diario y los resúmenes por periodo. Más precisos, usan más tokens." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "Ver también: {metric}", "continue": "Seguir buscando", "settingLabel": "Sugerir preguntas de seguimiento", - "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach." + "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach.", + "changeAssumption": "En su lugar: {value}" }, "clarify": { "cardLabel": "El Coach tiene una pregunta", @@ -9946,6 +10044,12 @@ "last90days": "Últimos 90 días", "lastYear": "Últimos 12 meses", "allTime": "Todo el periodo" + }, + "comparison": { + "baseline90d": "Comparar con mis 90 días habituales" + }, + "anchor": { + "illness": "Desde la enfermedad del {date}" } }, "reuse": { diff --git a/messages/fr.json b/messages/fr.json index 87a42569d..e13156a46 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "Mémoriser", - "done": "Enregistré dans vos informations personnelles", - "duplicate": "Déjà présent dans vos informations personnelles", - "failed": "Échec de l’enregistrement. Veuillez réessayer." + "failed": "Échec de l’enregistrement. Veuillez réessayer.", + "alreadyKept": "Le Coach s'en souvient déjà." }, "guided": { "offerTitle": "Le Coach a {count} questions pour vous", @@ -3019,7 +3018,7 @@ "hideConversations": "Masquer les conversations", "heroGreeting": "Pose-moi une question sur tes données", "heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.", - "thinkingElapsed": "{seconds}s", + "thinkingElapsed": "{seconds} s", "thinkingDuration": "A réfléchi pendant {seconds}s", "thinkingExpandAria": "Montrer ce que le Coach a pris en compte", "thinkingDetail": "Réponse établie à partir de ton aperçu santé.", @@ -3030,6 +3029,18 @@ "baselineWithin": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} s'y situe.", "baselineAbove": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe au-dessus.", "baselineBelow": "Ta plage habituelle est de {low}–{high} {unit} ; aujourd'hui, {value} se situe en dessous.", + "sameHoursWithin": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} s'y situe.", + "sameHoursAbove": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe au-dessus.", + "sameHoursBelow": "À ce moment de la journée, tu es habituellement à {low}–{high} {unit} ; aujourd'hui jusqu'ici, {value} se situe en dessous.", + "steadyWithin": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} correspond.", + "steadyAbove": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe au-dessus.", + "steadyBelow": "Tes valeurs sont habituellement à {low} {unit} ; aujourd'hui, {value} se situe en dessous.", + "latestWithin": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, dans cette plage.", + "latestAbove": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.", + "latestBelow": "Ta plage habituelle est de {low}–{high} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.", + "steadyLatestWithin": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, comme d'habitude.", + "steadyLatestAbove": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.", + "steadyLatestBelow": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.", "insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "{count} domaines consultés", "info": "Détails de la réponse", "infoModel": "Modèle : {model}" - } + }, + "activity": { + "thinking": "Réfléchit…", + "fetching": "Récupère {domain}, {window}…", + "digestOne": "Résume {count} mesure…", + "digestFew": "Résume {count} mesures…", + "digestOther": "Résume {count} mesures…", + "digestDoneOne": "{count} mesure sur {areas}", + "digestDoneFew": "{count} mesures sur {areas}", + "digestDoneOther": "{count} mesures sur {areas}", + "areasOne": "{count} domaine", + "areasFew": "{count} domaines", + "areasOther": "{count} domaines", + "memoryOne": "Se souvient de {count} chose que tu lui as dite", + "memoryFew": "Se souvient de {count} choses que tu lui as dites", + "memoryOther": "Se souvient de {count} choses que tu lui as dites", + "remember": "Retient quelque chose…", + "plan": "Propose un plan…", + "asking": "Te pose une question…", + "answer": "Rédige la réponse…", + "summaryOne": "Réflexion faite, {lookups} recherche, {seconds} s", + "summaryFew": "Réflexion faite, {lookups} recherches, {seconds} s", + "summaryOther": "Réflexion faite, {lookups} recherches, {seconds} s", + "summaryNoLookups": "Réflexion faite, {seconds} s", + "stop": { + "budget": "Budget atteint, répond avec ce qu'il a", + "time": "Temps écoulé, répond avec ce qu'il a", + "cap": "Beaucoup d'étapes, conclut maintenant", + "noProgress": "Plus de nouvelles données, conclut" + } + }, + "memory": { + "saved": "Retenu : {fact}", + "undo": "Annuler", + "accept": "Oui, retiens-le", + "decline": "Non", + "confirmed": "C'est noté, je m'en souviendrai.", + "declined": "D'accord, je ne le ferai pas." + }, + "plan": { + "accept": "Adopter ce plan", + "decline": "Pas maintenant", + "confirmed": "C'est noté. On fait le point ensemble dans {days} jours.", + "declined": "D'accord, pas maintenant." + }, + "assumption": { + "line": "Hypothèse : {value}", + "change": "Modifier" + }, + "suggestedReplies": { + "groupLabel": "Réponses suggérées" + }, + "interim": { + "labelOne": "{title}, {window}, {count} mesure", + "labelFew": "{title}, {window}, {count} mesures", + "labelOther": "{title}, {window}, {count} mesures" + }, + "chart": { + "compare": { + "periods": "{a} contre {b}" + } + }, + "reasoning": { + "label": "Profondeur de réflexion", + "hint": "Réfléchir plus en profondeur donne des réponses plus précises, avec un peu plus d'attente.", + "off": "Désactivé", + "minimal": "Minimale", + "low": "Faible", + "medium": "Moyenne", + "high": "Élevée", + "capped": "(limité par l'administrateur)", + "disabled": "L'administrateur a désactivé la réflexion approfondie." + }, + "memoryLink": "Mémoire ({count})" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -5992,7 +6076,6 @@ "categoryGoal": "Objectifs", "categoryConstraint": "Contraintes", "categoryContext": "Contexte", - "forget": "Oublier", "forgetAria": "Oublier cette information", "forgetTitle": "Oublier ce fait ?", "forgetBody": "Le Coach cesse de l’utiliser dès le message suivant. Un fait oublié ne peut pas être récupéré.", @@ -6008,7 +6091,8 @@ "forgotError": "Impossible d'oublier cette information. Veuillez réessayer.", "clearedToast": "{count} informations enregistrées oubliées.", "clearError": "Impossible d'effacer les informations enregistrées. Veuillez réessayer.", - "detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie." + "detail": "Il s'en sert pour garder ses réponses pertinentes. Supprimez tout ce qu'il vaut mieux qu'il oublie.", + "categoryMedication": "Médicaments" }, "coachReminders": { "title": "Tes rappels", @@ -6124,7 +6208,8 @@ "low": "Faible", "medium": "Moyen", "high": "Élevé" - } + }, + "coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "Coach activé.", "savedOff": "Coach masqué.", "saveError": "Impossible d enregistrer la modification. Veuillez réessayer." + }, + "memory": { + "edit": "Modifier", + "source": { + "user": "de toi", + "coach": "du Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7250,11 @@ "documentAi": { "title": "Lecture de documents", "description": "Lecture par IA des documents enregistrés, scan des comptes rendus de laboratoire et extraction de médicaments à partir de texte. La plus grande part des données qui quittent le serveur." + }, + "reasoning": { + "enabled": "Autoriser la réflexion approfondie", + "max": "Profondeur de réflexion maximale", + "description": "Les modèles réfléchissent avant de répondre : le Coach, le briefing quotidien et les synthèses de période. Plus précis, plus de jetons." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "Voir 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." + "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach.", + "changeAssumption": "À la place : {value}" }, "clarify": { "cardLabel": "Le Coach a une question", @@ -9946,6 +10044,12 @@ "last90days": "90 derniers jours", "lastYear": "12 derniers mois", "allTime": "Toute la période" + }, + "comparison": { + "baseline90d": "Comparer avec mes 90 jours habituels" + }, + "anchor": { + "illness": "Depuis la maladie du {date}" } }, "reuse": { diff --git a/messages/it.json b/messages/it.json index 8a150e8a1..bda0a89a9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "Memorizza", - "done": "Salvato nelle tue informazioni personali", - "duplicate": "Già presente nelle tue informazioni personali", - "failed": "Salvataggio non riuscito. Riprova." + "failed": "Salvataggio non riuscito. Riprova.", + "alreadyKept": "Il Coach lo ricorda già." }, "guided": { "offerTitle": "Il Coach ha {count} domande per te", @@ -3019,7 +3018,7 @@ "hideConversations": "Nascondi conversazioni", "heroGreeting": "Chiedimi qualcosa sui tuoi dati", "heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.", - "thinkingElapsed": "{seconds}s", + "thinkingElapsed": "{seconds} s", "thinkingDuration": "Ha riflettuto per {seconds}s", "thinkingExpandAria": "Mostra cosa ha considerato il Coach", "thinkingDetail": "Risposta basata sul tuo riepilogo di salute.", @@ -3030,6 +3029,18 @@ "baselineWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} rientra al suo interno.", "baselineAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sopra.", "baselineBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; oggi {value} si colloca al di sotto.", + "sameHoursWithin": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} rientra nell'intervallo.", + "sameHoursAbove": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sopra.", + "sameHoursBelow": "A quest'ora del giorno di solito sei a {low}–{high} {unit}; oggi finora {value} si colloca al di sotto.", + "steadyWithin": "I tuoi valori sono di solito {low} {unit}; oggi {value} corrisponde.", + "steadyAbove": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sopra.", + "steadyBelow": "I tuoi valori sono di solito {low} {unit}; oggi {value} è al di sotto.", + "latestWithin": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al suo interno.", + "latestAbove": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.", + "latestBelow": "Il tuo intervallo abituale è {low}–{high} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.", + "steadyLatestWithin": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, in linea.", + "steadyLatestAbove": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.", + "steadyLatestBelow": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.", "insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "{count} aree consultate", "info": "Dettagli della risposta", "infoModel": "Modello: {model}" - } + }, + "activity": { + "thinking": "Sta pensando…", + "fetching": "Recupera {domain}, {window}…", + "digestOne": "Riassume {count} valore…", + "digestFew": "Riassume {count} valori…", + "digestOther": "Riassume {count} valori…", + "digestDoneOne": "{count} valore da {areas}", + "digestDoneFew": "{count} valori da {areas}", + "digestDoneOther": "{count} valori da {areas}", + "areasOne": "{count} area", + "areasFew": "{count} aree", + "areasOther": "{count} aree", + "memoryOne": "Ricorda {count} cosa che gli hai detto", + "memoryFew": "Ricorda {count} cose che gli hai detto", + "memoryOther": "Ricorda {count} cose che gli hai detto", + "remember": "Memorizza qualcosa…", + "plan": "Prepara un piano…", + "asking": "Ti fa una domanda…", + "answer": "Scrive la risposta…", + "summaryOne": "Ci ha pensato, {lookups} ricerca, {seconds} s", + "summaryFew": "Ci ha pensato, {lookups} ricerche, {seconds} s", + "summaryOther": "Ci ha pensato, {lookups} ricerche, {seconds} s", + "summaryNoLookups": "Ci ha pensato, {seconds} s", + "stop": { + "budget": "Budget raggiunto, risponde con ciò che ha", + "time": "Tempo scaduto, risponde con ciò che ha", + "cap": "Molti passaggi, ora riassume", + "noProgress": "Nessun dato nuovo, riassume" + } + }, + "memory": { + "saved": "Memorizzato: {fact}", + "undo": "Annulla", + "accept": "Sì, ricordalo", + "decline": "No", + "confirmed": "Ricevuto, me lo ricorderò.", + "declined": "D'accordo, non lo faccio." + }, + "plan": { + "accept": "Adotta questo piano", + "decline": "Non ora", + "confirmed": "Annotato. Ne riparliamo insieme tra {days} giorni.", + "declined": "D'accordo, non ora." + }, + "assumption": { + "line": "Ipotesi: {value}", + "change": "Cambia" + }, + "suggestedReplies": { + "groupLabel": "Risposte suggerite" + }, + "interim": { + "labelOne": "{title}, {window}, {count} valore", + "labelFew": "{title}, {window}, {count} valori", + "labelOther": "{title}, {window}, {count} valori" + }, + "chart": { + "compare": { + "periods": "{a} rispetto a {b}" + } + }, + "reasoning": { + "label": "Profondità di ragionamento", + "hint": "Pensare più a fondo significa risposte più precise e un po' più di attesa.", + "off": "Disattivato", + "minimal": "Minima", + "low": "Bassa", + "medium": "Media", + "high": "Alta", + "capped": "(limitato dall'amministratore)", + "disabled": "L'amministratore ha disattivato il ragionamento approfondito." + }, + "memoryLink": "Memoria ({count})" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -5992,7 +6076,6 @@ "categoryGoal": "Obiettivi", "categoryConstraint": "Vincoli", "categoryContext": "Contesto", - "forget": "Dimentica", "forgetAria": "Dimentica questa informazione", "forgetTitle": "Dimenticare questa informazione?", "forgetBody": "Il Coach smette di usarla dal messaggio successivo. Un’informazione dimenticata non può essere recuperata.", @@ -6008,7 +6091,8 @@ "forgotError": "Impossibile dimenticare questa informazione. Riprova.", "clearedToast": "Dimenticate {count} informazioni memorizzate.", "clearError": "Impossibile cancellare le informazioni memorizzate. Riprova.", - "detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi." + "detail": "Le usa per mantenere pertinenti le sue risposte. Rimuovi tutto ciò che preferisci dimentichi.", + "categoryMedication": "Farmaci" }, "coachReminders": { "title": "I tuoi promemoria", @@ -6124,7 +6208,8 @@ "low": "Basso", "medium": "Medio", "high": "Alto" - } + }, + "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "Coach attivato.", "savedOff": "Coach nascosto.", "saveError": "Impossibile salvare la modifica. Riprova." + }, + "memory": { + "edit": "Modifica", + "source": { + "user": "da te", + "coach": "dal Coach" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7250,11 @@ "documentAi": { "title": "Lettura dei documenti", "description": "Lettura con IA dei documenti salvati, scansione dei referti di laboratorio ed estrazione dei farmaci dal testo. La quota maggiore dei dati che lasciano il server." + }, + "reasoning": { + "enabled": "Consenti ragionamento approfondito", + "max": "Profondità massima di ragionamento", + "description": "I modelli riflettono prima di rispondere: il Coach, il briefing giornaliero e i riepiloghi di periodo. Più precisi, usano più token." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "Vedi anche: {metric}", "continue": "Continua a cercare", "settingLabel": "Suggerisci domande di approfondimento", - "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach." + "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach.", + "changeAssumption": "Invece: {value}" }, "clarify": { "cardLabel": "Il Coach ha una domanda", @@ -9946,6 +10044,12 @@ "last90days": "Ultimi 90 giorni", "lastYear": "Ultimi 12 mesi", "allTime": "Intero periodo" + }, + "comparison": { + "baseline90d": "Confronta con i miei soliti 90 giorni" + }, + "anchor": { + "illness": "Dalla malattia del {date}" } }, "reuse": { diff --git a/messages/ko.json b/messages/ko.json index 7c6d3661f..42368be2b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "기억하기", - "done": "셀프 컨텍스트에 저장했어요", - "duplicate": "이미 셀프 컨텍스트에 있어요", - "failed": "저장하지 못했어요. 다시 시도해 주세요." + "failed": "저장하지 못했어요. 다시 시도해 주세요.", + "alreadyKept": "코치가 이미 기억하고 있어요." }, "guided": { "offerTitle": "코치가 물어볼 후속 질문이 {count}개 있어요", @@ -3030,6 +3029,18 @@ "baselineWithin": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 안이에요.", "baselineAbove": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 위예요.", "baselineBelow": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 아래예요.", + "sameHoursWithin": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 안이에요.", + "sameHoursAbove": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 위예요.", + "sameHoursBelow": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 아래예요.", + "steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value} — 평소와 같아요.", + "steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 높아요.", + "steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 낮아요.", + "latestWithin": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 안이었어요.", + "latestAbove": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위보다 높았어요.", + "latestBelow": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 아래였어요.", + "steadyLatestWithin": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소와 같았어요.", + "steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 높았어요.", + "steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 낮았어요.", "insufficient": "아직 평소 범위를 배우는 중이에요 — 며칠 더 기록해 주세요.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "영역 {count}개 확인", "info": "답변 정보", "infoModel": "모델: {model}" - } + }, + "activity": { + "thinking": "생각하는 중…", + "fetching": "{domain} 가져오는 중, {window}…", + "digestOne": "값 {count}개 요약 중…", + "digestFew": "값 {count}개 요약 중…", + "digestOther": "값 {count}개 요약 중…", + "digestDoneOne": "{areas}에서 값 {count}개", + "digestDoneFew": "{areas}에서 값 {count}개", + "digestDoneOther": "{areas}에서 값 {count}개", + "areasOne": "{count}개 영역", + "areasFew": "{count}개 영역", + "areasOther": "{count}개 영역", + "memoryOne": "알려 준 내용 {count}개를 떠올리는 중", + "memoryFew": "알려 준 내용 {count}개를 떠올리는 중", + "memoryOther": "알려 준 내용 {count}개를 떠올리는 중", + "remember": "기억해 두는 중…", + "plan": "계획을 제안하는 중…", + "asking": "질문하는 중…", + "answer": "답변을 쓰는 중…", + "summaryOne": "생각 완료, 조회 {lookups}회, {seconds}초", + "summaryFew": "생각 완료, 조회 {lookups}회, {seconds}초", + "summaryOther": "생각 완료, 조회 {lookups}회, {seconds}초", + "summaryNoLookups": "생각 완료, {seconds}초", + "stop": { + "budget": "예산 도달, 지금까지의 내용으로 답변", + "time": "시간 제한 도달, 지금까지의 내용으로 답변", + "cap": "단계가 많아 이제 정리하는 중", + "noProgress": "새 데이터가 없어 정리하는 중" + } + }, + "memory": { + "saved": "기억함: {fact}", + "undo": "실행 취소", + "accept": "네, 기억해 줘", + "decline": "아니요", + "confirmed": "알겠어요, 기억해 둘게요.", + "declined": "알겠어요, 저장하지 않을게요." + }, + "plan": { + "accept": "이 계획 시작하기", + "decline": "나중에", + "confirmed": "기록했어요. {days}일 뒤에 함께 살펴볼게요.", + "declined": "알겠어요, 지금은 하지 않을게요." + }, + "assumption": { + "line": "가정: {value}", + "change": "변경" + }, + "suggestedReplies": { + "groupLabel": "추천 답변" + }, + "interim": { + "labelOne": "{title}, {window}, 값 {count}개", + "labelFew": "{title}, {window}, 값 {count}개", + "labelOther": "{title}, {window}, 값 {count}개" + }, + "chart": { + "compare": { + "periods": "{a} 대 {b}" + } + }, + "reasoning": { + "label": "생각 깊이", + "hint": "더 깊이 생각할수록 답변이 정확해지고 조금 더 기다리게 돼요.", + "off": "끄기", + "minimal": "최소", + "low": "낮음", + "medium": "중간", + "high": "높음", + "capped": "(관리자가 제한함)", + "disabled": "관리자가 깊은 생각을 꺼 두었어요." + }, + "memoryLink": "기억 ({count})" }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", @@ -5992,7 +6076,6 @@ "categoryGoal": "목표", "categoryConstraint": "제약", "categoryContext": "맥락", - "forget": "잊기", "forgetAria": "이 정보 잊기", "forgetTitle": "이 정보를 잊을까요?", "forgetBody": "다음 메시지부터 코치가 이 정보를 쓰지 않아요. 한번 잊은 정보는 되돌릴 수 없어요.", @@ -6008,7 +6091,8 @@ "forgotError": "그 정보를 잊지 못했어요. 다시 시도해 주세요.", "clearedToast": "저장된 정보 {count}개를 잊었어요.", "clearError": "저장된 정보를 지우지 못했어요. 다시 시도해 주세요.", - "detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요." + "detail": "코치는 이 정보로 답변을 상황에 맞게 유지해요. 잊었으면 하는 건 지우세요.", + "categoryMedication": "약" }, "coachReminders": { "title": "내 알림", @@ -6124,7 +6208,8 @@ "low": "낮음", "medium": "보통", "high": "높음" - } + }, + "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "코치를 켰어요.", "savedOff": "코치를 숨겼어요.", "saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요." + }, + "memory": { + "edit": "편집", + "source": { + "user": "내가 알려 줌", + "coach": "코치가 기억함" + } } }, "integrationRedirectGuide": { @@ -7137,6 +7229,11 @@ "documentAi": { "title": "문서 읽기", "description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다." + }, + "reasoning": { + "enabled": "깊은 생각 허용", + "max": "최대 생각 깊이", + "description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "함께 보기: {metric}", "continue": "계속 찾아보기", "settingLabel": "후속 질문 추천", - "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요." + "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요.", + "changeAssumption": "대신: {value}" }, "clarify": { "cardLabel": "코치가 질문이 있어요", @@ -9946,6 +10044,12 @@ "last90days": "최근 90일", "lastYear": "최근 12개월", "allTime": "전체 기간" + }, + "comparison": { + "baseline90d": "평소 90일과 비교하기" + }, + "anchor": { + "illness": "{date} 질환 이후부터" } }, "reuse": { diff --git a/messages/pl.json b/messages/pl.json index c96054e1a..c26366b41 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2836,9 +2836,8 @@ }, "rememberMessage": { "action": "Zapamiętaj", - "done": "Zapisano w informacjach o sobie", - "duplicate": "Już znajduje się w informacjach o sobie", - "failed": "Nie udało się zapisać. Spróbuj ponownie." + "failed": "Nie udało się zapisać. Spróbuj ponownie.", + "alreadyKept": "Coach już to pamięta." }, "guided": { "offerTitle": "Coach ma {count} pytania do Ciebie", @@ -3019,7 +3018,7 @@ "hideConversations": "Ukryj rozmowy", "heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych", "heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.", - "thinkingElapsed": "{seconds}s", + "thinkingElapsed": "{seconds} s", "thinkingDuration": "Myślał przez {seconds}s", "thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę", "thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.", @@ -3030,6 +3029,18 @@ "baselineWithin": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} mieści się w nim.", "baselineAbove": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest powyżej niego.", "baselineBelow": "Twój zwykły zakres to {low}–{high} {unit}; dzisiejsza wartość {value} jest poniżej niego.", + "sameHoursWithin": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli w zakresie.", + "sameHoursAbove": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli powyżej.", + "sameHoursBelow": "Do tej pory dnia zwykle masz {low}–{high} {unit}; dziś jak dotąd {value}, czyli poniżej.", + "steadyWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest taka sama.", + "steadyAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest wyższa.", + "steadyBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; dzisiejsza wartość {value} jest niższa.", + "latestWithin": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i mieścił się w nim.", + "latestAbove": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był powyżej niego.", + "latestBelow": "Twój zwykły zakres to {low}–{high} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był poniżej niego.", + "steadyLatestWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był taki sam.", + "steadyLatestAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był wyższy.", + "steadyLatestBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był niższy.", "insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.", "driver": "{note}" }, @@ -3146,7 +3157,80 @@ "areasDoneOther": "Sprawdzono {count} obszarów", "info": "Szczegóły odpowiedzi", "infoModel": "Model: {model}" - } + }, + "activity": { + "thinking": "Myśli…", + "fetching": "Pobiera {domain}, {window}…", + "digestOne": "Podsumowuje {count} pomiar…", + "digestFew": "Podsumowuje {count} pomiary…", + "digestOther": "Podsumowuje {count} pomiarów…", + "digestDoneOne": "{count} pomiar z: {areas}", + "digestDoneFew": "{count} pomiary z: {areas}", + "digestDoneOther": "{count} pomiarów z: {areas}", + "areasOne": "{count} obszar", + "areasFew": "{count} obszary", + "areasOther": "{count} obszarów", + "memoryOne": "Pamięta {count} rzecz, którą mu powiedziałeś", + "memoryFew": "Pamięta {count} rzeczy, które mu powiedziałeś", + "memoryOther": "Pamięta {count} rzeczy, które mu powiedziałeś", + "remember": "Zapamiętuje coś…", + "plan": "Przygotowuje plan…", + "asking": "Dopytuje…", + "answer": "Pisze odpowiedź…", + "summaryOne": "Przemyślane, {lookups} zapytanie, {seconds} s", + "summaryFew": "Przemyślane, {lookups} zapytania, {seconds} s", + "summaryOther": "Przemyślane, {lookups} zapytań, {seconds} s", + "summaryNoLookups": "Przemyślane, {seconds} s", + "stop": { + "budget": "Budżet wyczerpany, odpowiada na podstawie tego, co ma", + "time": "Limit czasu osiągnięty, odpowiada na podstawie tego, co ma", + "cap": "Wiele kroków, teraz podsumowuje", + "noProgress": "Brak nowych danych, podsumowuje" + } + }, + "memory": { + "saved": "Zapamiętane: {fact}", + "undo": "Cofnij", + "accept": "Tak, zapamiętaj", + "decline": "Nie", + "confirmed": "Jasne, zapamiętam.", + "declined": "Dobrze, nie zapiszę tego." + }, + "plan": { + "accept": "Przyjmij ten plan", + "decline": "Nie teraz", + "confirmed": "Zanotowane. Wrócimy do tego razem za {days} dni.", + "declined": "Dobrze, nie teraz." + }, + "assumption": { + "line": "Założenie: {value}", + "change": "Zmień" + }, + "suggestedReplies": { + "groupLabel": "Proponowane odpowiedzi" + }, + "interim": { + "labelOne": "{title}, {window}, {count} pomiar", + "labelFew": "{title}, {window}, {count} pomiary", + "labelOther": "{title}, {window}, {count} pomiarów" + }, + "chart": { + "compare": { + "periods": "{a} wobec {b}" + } + }, + "reasoning": { + "label": "Głębokość namysłu", + "hint": "Głębszy namysł oznacza dokładniejsze odpowiedzi i trochę dłuższe czekanie.", + "off": "Wyłączone", + "minimal": "Minimalna", + "low": "Niska", + "medium": "Średnia", + "high": "Wysoka", + "capped": "(ograniczone przez administratora)", + "disabled": "Administrator wyłączył głębszy namysł." + }, + "memoryLink": "Pamięć ({count})" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -5992,7 +6076,6 @@ "categoryGoal": "Cele", "categoryConstraint": "Ograniczenia", "categoryContext": "Kontekst", - "forget": "Zapomnij", "forgetAria": "Zapomnij tę informację", "forgetTitle": "Zapomnieć tę informację?", "forgetBody": "Coach przestanie z niej korzystać od następnej wiadomości. Zapomnianej informacji nie da się odzyskać.", @@ -6008,7 +6091,8 @@ "forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.", "clearedToast": "Zapomniano {count} zapisanych informacji.", "clearError": "Nie udało się usunąć zapisanych informacji. Spróbuj ponownie.", - "detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał." + "detail": "Wykorzystuje je, aby jego odpowiedzi pozostawały trafne. Usuń wszystko, o czym wolisz, by zapomniał.", + "categoryMedication": "Leki" }, "coachReminders": { "title": "Twoje przypomnienia", @@ -6124,7 +6208,8 @@ "low": "Niskie", "medium": "Średnie", "high": "Wysokie" - } + }, + "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha." } }, "dashboard": { @@ -6663,6 +6748,13 @@ "savedOn": "Coach włączony.", "savedOff": "Coach ukryty.", "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." + }, + "memory": { + "edit": "Edytuj", + "source": { + "user": "od ciebie", + "coach": "od Coacha" + } } }, "integrationRedirectGuide": { @@ -7158,6 +7250,11 @@ "documentAi": { "title": "Czytanie dokumentów", "description": "Czytanie zapisanych dokumentów przez AI, skanowanie wyników badań laboratoryjnych i odczytywanie leków z tekstu. Największa część danych opuszczających serwer." + }, + "reasoning": { + "enabled": "Zezwalaj na głębszy namysł", + "max": "Największa głębokość namysłu", + "description": "Modele myślą przed odpowiedzią: Coach, codzienne podsumowanie i podsumowania okresów. Dokładniej, ale więcej tokenów." } }, "modules": { @@ -9933,7 +10030,8 @@ "relatedMetric": "Sprawdź też: {metric}", "continue": "Szukaj dalej", "settingLabel": "Proponuj kolejne pytania", - "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha." + "settingHint": "Pokazuje do trzech propozycji pod ostatnią odpowiedzią Coacha.", + "changeAssumption": "Zamiast tego: {value}" }, "clarify": { "cardLabel": "Coach ma pytanie", @@ -9946,6 +10044,12 @@ "last90days": "Ostatnie 90 dni", "lastYear": "Ostatnie 12 miesięcy", "allTime": "Cały okres" + }, + "comparison": { + "baseline90d": "Porównaj z moimi zwykłymi 90 dniami" + }, + "anchor": { + "illness": "Od choroby z {date}" } }, "reuse": { diff --git a/package.json b/package.json index 022eeaa01..b43f4d3c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.40.6", + "version": "1.41.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", @@ -59,7 +59,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.9.1", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/sdk": "^1.32.1", "@napi-rs/canvas": "1.0.9", "@node-rs/argon2": "^2.1.0", "@parse/node-apn": "^8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7aced555..215c1f798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,7 @@ overrides: qs@<6.16.0: ^6.16.0 fast-uri@<3.1.8: ^3.1.8 flatted@<3.4.2: ^3.4.2 + proxy-addr@<2.0.8: ^2.0.8 mysql2@<3.22.0: ^3.22.0 minimatch@<3.1.4: ^3.1.4 ip-address@<10.7.1: ^10.7.1 @@ -56,8 +57,8 @@ importers: specifier: ^5.9.1 version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3) '@modelcontextprotocol/sdk': - specifier: ^1.30.0 - version: 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3) + specifier: ^1.32.1 + version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3) '@napi-rs/canvas': specifier: 1.0.9 version: 1.0.9 @@ -1039,8 +1040,8 @@ packages: '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} - '@modelcontextprotocol/sdk@1.30.0': - resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -5870,8 +5871,8 @@ packages: resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} engines: {node: '>=12.0.0'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} pump@3.0.4: @@ -7852,7 +7853,7 @@ snapshots: '@levischuck/tiny-cbor@0.2.11': {} - '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 @@ -7876,7 +7877,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)': + '@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 @@ -11248,7 +11249,7 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 + proxy-addr: 2.0.8 qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0(supports-color@7.2.0) @@ -12744,7 +12745,7 @@ snapshots: '@types/node': 26.6.2 long: 5.3.2 - proxy-addr@2.0.7: + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -13198,7 +13199,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) '@dotenvx/dotenvx': 1.75.1 - '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.7 commander: 14.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1304d0c1..a35c1a52d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -38,6 +38,10 @@ overrides: # follow-up fixed in 3.1.8, raised the floor from 3.1.6. "fast-uri@<3.1.8": "^3.1.8" "flatted@<3.4.2": "^3.4.2" + # GHSA-jqcg-44mw-7w3h: IP spoofing through IPv4-mapped addresses. It + # arrives under express in the MCP SDK; the app resolves client IPs itself, + # but the copy sits on a production path. + "proxy-addr@<2.0.8": "^2.0.8" # GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials. # Reached only through prisma's MySQL driver, which this app never opens; # pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the diff --git a/prisma/migrations/0376_ai_reasoning_controls/migration.sql b/prisma/migrations/0376_ai_reasoning_controls/migration.sql new file mode 100644 index 000000000..54c6f36cc --- /dev/null +++ b/prisma/migrations/0376_ai_reasoning_controls/migration.sql @@ -0,0 +1,6 @@ +-- The operator's reasoning controls on the instance row. Both columns carry a +-- default, so the singleton row reads "reasoning allowed, no cap" the moment +-- the migration lands and an upgrade needs no admin action. +ALTER TABLE "app_settings" + ADD COLUMN "ai_reasoning_enabled" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "ai_reasoning_max_effort" TEXT NOT NULL DEFAULT 'high'; diff --git a/prisma/migrations/0377_coach_message_trail/migration.sql b/prisma/migrations/0377_coach_message_trail/migration.sql new file mode 100644 index 000000000..a3fe696d9 --- /dev/null +++ b/prisma/migrations/0377_coach_message_trail/migration.sql @@ -0,0 +1,4 @@ +-- The model-written text of a Coach turn's live trail, encrypted like the +-- message itself. Nullable and without a default: every existing turn has no +-- trail, and adding a nullable column rewrites no rows. +ALTER TABLE "coach_messages" ADD COLUMN "trail_encrypted" BYTEA; diff --git a/prisma/migrations/0378_coach_fact_source/migration.sql b/prisma/migrations/0378_coach_fact_source/migration.sql new file mode 100644 index 000000000..f79dab9b8 --- /dev/null +++ b/prisma/migrations/0378_coach_fact_source/migration.sql @@ -0,0 +1,8 @@ +-- Where a Coach fact came from, when it was last used in a turn, and the +-- message it came out of. Every row written before this migration came from +-- the background extraction or the deterministic matcher, which could not be +-- told apart, so they all read as `extracted`. +ALTER TABLE "coach_facts" + ADD COLUMN "source" TEXT NOT NULL DEFAULT 'extracted', + ADD COLUMN "last_used_at" TIMESTAMP(3), + ADD COLUMN "source_message_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d5b691e19..dcd081188 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4950,6 +4950,17 @@ model AppSettings { assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled") + // v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches + // provider-side reasoning off for every surface (the Coach and the three + // background jobs that use it); `aiReasoningMaxEffort` caps the level a + // person may pick (`low` | `medium` | `high`, an app-side closed enum in + // `src/lib/ai/reasoning/levels.ts`). Resolved once per turn or job by + // `src/lib/ai/reasoning/resolve.ts` and published per record on + // `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no + // cap, so an upgrade needs no admin action. + aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled") + aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort") + // v1.18.0 — operator-level server-wide module availability. A DISABLED // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / // empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY @@ -6435,6 +6446,13 @@ model CoachMessage { // 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") + // v1.41 — the model-written text of the turn's live trail (JSON + // `CoachTrail`, at most 16 KB): reasoning titles and texts per round, the + // facts recalled into the turn, the text of a fact proposal. AES-256-GCM + // like `encryptedContent`. The trail's plaintext metadata (phase, status, + // round, catalog label, duration, count, stop reason) rides + // `metricSourceJson.activity`. Owner-only; NULL on every turn without one. + trailEncrypted Bytes? @map("trail_encrypted") createdAt DateTime @default(now()) @map("created_at") conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@ -6477,6 +6495,18 @@ model CoachFact { updatedAt DateTime @updatedAt @map("updated_at") // Soft-delete: hides the fact from injection but keeps the row for audit. deletedAt DateTime? @map("deleted_at") + // v1.41 — who put the fact here, an app-side closed enum + // (`COACH_FACT_SOURCES` in `src/lib/ai/coach/memory/contract.ts`): `user` + // (the remember button, or a proposal the person confirmed), `coach` (saved + // during a turn), `extracted` (the background extraction, and every row + // written before the column existed), `pattern` (the deterministic matcher). + source String @default("extracted") + // v1.41 — when the fact last went into a Coach turn; NULL until it has. + lastUsedAt DateTime? @map("last_used_at") + // v1.41 — the message the fact came out of, for "undo" and "from this + // conversation". A bare id like `sourceConversationId`, not a relation, so + // deleting a conversation does not take the fact with it. + sourceMessageId String? @map("source_message_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index ffc137f30..6e28c1d91 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -462,8 +462,8 @@ async function main() { } // ───── Coach (Bytes columns) ───── - // "encryptedContent" "resultsEncrypted" "summaryEncrypted" "titleEncrypted" - // "factEncrypted" + // "encryptedContent" "resultsEncrypted" "trailEncrypted" "summaryEncrypted" + // "titleEncrypted" "factEncrypted" results.push( await rotateBytesColumn( "CoachMessage", @@ -480,6 +480,15 @@ async function main() { prisma.coachMessage, ), ); + // v1.41 — the model text of the turn's trail. NULL on every turn without + // one, which `rotateBytesColumn` skips. + results.push( + await rotateBytesColumn( + "CoachMessage", + "trailEncrypted", + prisma.coachMessage, + ), + ); results.push( await rotateBytesColumn( "CoachConversation", diff --git a/src/__tests__/ai-reasoning-admin-guard.test.ts b/src/__tests__/ai-reasoning-admin-guard.test.ts new file mode 100644 index 000000000..221419db5 --- /dev/null +++ b/src/__tests__/ai-reasoning-admin-guard.test.ts @@ -0,0 +1,176 @@ +/** + * v1.41 — the operator's reasoning switch and cap cannot be bypassed. + * + * Two halves. + * + * Behaviour: `resolveReasoning` is the one decision. With the switch off it + * answers `off` for the Coach and every background job, whatever the person + * chose, whoever pays and whatever the provider can do; with it on, no answer + * is above the cap. Proven exhaustively over the whole input space. + * + * Structure: a call carries reasoning only through `CompletionParams.reasoning`. + * Every production file outside the provider clients that puts that key on a + * call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or + * `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }` + * would skip the switch, and goes red here. + * + * Its limit: the structural half reads source text for the setter shapes in + * use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`, + * `reasoning: resolveJobReasoning(`). A value laundered through another name + * slips it; the resolver import is still required of the file that sets it. + * + * Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts` + * with `{ effort: "high", summaries: false }` and drop the import: red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + BACKGROUND_REASONING_JOBS, + REASONING_LEVELS, + REASONING_MAX_EFFORTS, +} from "@/lib/ai/reasoning/levels"; +import { + completionReasoning, + resolveReasoning, + type ReasoningSurface, +} from "@/lib/ai/reasoning/resolve"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS]; + +describe("the switch and the cap win, behaviourally", () => { + const supports = [ + null, + { + effort: true, + summaries: true, + liveSummaries: true, + stateRoundTrip: true, + offIsReal: false, + }, + ]; + + it("off reaches no surface as anything but off", () => { + let cases = 0; + for (const surface of SURFACES) + for (const userPref of REASONING_LEVELS) + for (const costOwner of ["user", "operator"] as const) + for (const support of supports) { + const r = resolveReasoning({ + surface, + userPref, + admin: { enabled: false, maxEffort: "high" }, + costOwner, + support, + }); + expect(r.effort).toBe("off"); + expect(r.source).toBe("admin_off"); + const sent = completionReasoning(r, surface); + // A job sends nothing; the Coach sends an explicit off. + if (surface === "coach") { + expect(sent).toEqual({ effort: "off", summaries: false }); + } else { + expect(sent).toBeUndefined(); + } + cases++; + } + expect(cases).toBeGreaterThanOrEqual(100); + }); + + it("no surface ever resolves above the cap", () => { + for (const surface of SURFACES) + for (const userPref of REASONING_LEVELS) + for (const maxEffort of REASONING_MAX_EFFORTS) { + const r = resolveReasoning({ + surface, + userPref, + admin: { enabled: true, maxEffort }, + costOwner: "user", + }); + expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual( + REASONING_LEVELS.indexOf(maxEffort), + ); + } + }); +}); + +/** The provider clients map an already-resolved value onto their wire. */ +const WIRE_LAYER = (rel: string) => + /^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) || + rel === "lib/ai/provider-runner.ts" || + rel.startsWith("lib/ai/reasoning/"); + +const SETTERS = [ + /\breasoning\s*:\s*\{\s*effort\b/, + /\{\s*reasoning\s*\}/, + /\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/, +]; + +const RESOLVER_IMPORT = + /from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/; + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("every call that carries reasoning took it from the resolver", () => { + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + // Client components bind a form field called `reasoning`, not a call. + .filter((p) => !p.startsWith("components/")) + .filter((p) => !WIRE_LAYER(p)); + const setters = files.filter((rel) => { + const code = stripComments(readFileSync(join(SRC, rel), "utf8")); + return SETTERS.some((re) => re.test(code)); + }); + + it("finds the setters (an empty set is a failure, not a pass)", () => { + expect(setters.length).toBeGreaterThanOrEqual(2); + expect(setters).toContain("lib/insights/status-provider.ts"); + expect(setters).toContain("lib/insights/briefing-provider.ts"); + }); + + it("each one imports the resolver", () => { + expect( + setters.filter( + (rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")), + ), + "A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.", + ).toEqual([]); + }); +}); + +/** + * The Coach turn receives its level on `TurnInput.reasoningLevel` and carries + * it through the loop under its own names, which the setter shapes above do + * not see. The one place that level is decided is the chat route, so the + * route is pinned: it resolves the level, and it does not fall back to a + * constant that would skip the operator's switch. + */ +describe("the Coach turn's level comes from the resolver", () => { + const route = stripComments( + readFileSync(join(SRC, "app/api/insights/chat/route.ts"), "utf8"), + ); + + it("resolves the level in the chat route", () => { + expect(route).toMatch( + /reasoningLevel[^=]*=\s*await\s+resolveCoachTurnReasoningLevel\s*\(/, + ); + expect(route).toMatch(RESOLVER_IMPORT); + }); + + it("never hands the turn a constant level", () => { + expect(route).not.toMatch(/\bDEFAULT_REASONING_LEVEL\b/); + expect(route).not.toMatch(/reasoningLevel\s*:\s*["']/); + }); +}); diff --git a/src/__tests__/ai-reasoning-effort-guard.test.ts b/src/__tests__/ai-reasoning-effort-guard.test.ts index e555ab141..2c4fac64e 100644 --- a/src/__tests__/ai-reasoning-effort-guard.test.ts +++ b/src/__tests__/ai-reasoning-effort-guard.test.ts @@ -3,11 +3,15 @@ * the setting the resolver bound to the provider instance. * * Default must send nothing, so the key may not appear on any wire except as - * the bound value, and only the two clients whose endpoint a person chose may - * carry it. Pinned here: + * the bound value or the call's own resolved level, and only the two clients + * whose endpoint a person chose may carry it. Pinned here: * * 1. the key appears in exactly the Local client and the OpenAI client, - * once each, and only as `reasoning_effort: this.reasoningEffort`; + * twice each: once as `reasoning_effort: this.reasoningEffort`, sent + * only when the call carries no level of its own (`!params.reasoning`), + * and once as the call's level (v1.41 `CompletionParams.reasoning`, + * resolved upstream against the person's choice and the operator's + * cap), never as a literal; * 2. nothing but the binder assigns `.reasoningEffort`; * 3. every exported resolver in `provider.ts` calls `bindReasoningEffort`. * @@ -20,6 +24,7 @@ * the behaviour on the wire. * * Mutation check: hard-code `reasoning_effort: "none"` into either client, + * drop the `!params.reasoning` condition in front of the bound value, * set `client.reasoningEffort = ...` in a route, or drop `bindReasoningEffort` * from `resolveProviderForTest`: each one goes red here. */ @@ -60,13 +65,17 @@ describe("reasoning_effort on the wire", () => { }); it.each(["lib/ai/local-client.ts", "lib/ai/openai-client.ts"])( - "%s sends it once, as the bound value", + "%s sends the bound value only without a call level, and never a literal", (rel) => { const source = code(rel); - expect(source.match(new RegExp(KEY))?.length).toBe(1); + expect(source.match(new RegExp(KEY))?.length).toBe(2); expect(source).toMatch( /\breasoning_effort\s*:\s*this\s*\.\s*reasoningEffort\b/, ); + expect(source).toMatch( + /!\s*params\s*\.\s*reasoning\s*&&\s*(?:this\s*\.\s*isGateway\s*&&\s*)?this\s*\.\s*reasoningEffort\b/, + ); + expect(source).not.toMatch(/\breasoning_effort["']?\s*:\s*["'`]/); }, ); }); diff --git a/src/__tests__/backfill-preserves-updated-at-guard.test.ts b/src/__tests__/backfill-preserves-updated-at-guard.test.ts index 9ee7b02ec..5f3238eb7 100644 --- a/src/__tests__/backfill-preserves-updated-at-guard.test.ts +++ b/src/__tests__/backfill-preserves-updated-at-guard.test.ts @@ -118,11 +118,17 @@ const BACKFILL_STAMPS: Record = { /** * `coachConversation` writes outside backfills that stamp `updatedAt` - * because the person did something in the conversation, keyed by file. + * because the person did something in the conversation, keyed by file, with + * how many such writes the file holds. The count matters: keyed by file + * alone, a new background write added beside the listed ones would pass + * unseen. */ -const COACH_ACTIVITY: Record = { - "src/lib/ai/coach/persistence.ts": - "a turn sets updatedAt to now explicitly; a rename and a document attach are the person acting on the thread", +const COACH_ACTIVITY: Record = { + "src/lib/ai/coach/persistence.ts": { + writes: 2, + reason: + "a rename and a document attach are the person acting on the thread (a turn names updatedAt explicitly)", + }, }; describe("backfills keep updatedAt", () => { @@ -180,13 +186,21 @@ describe("coach conversations move only on activity", () => { }); it("names updatedAt in every write that is not the person's own action", () => { - const offenders = writes - .filter((w) => !w.namesUpdatedAt && !(w.file in COACH_ACTIVITY)) - .map((w) => w.file); + const stamping = new Map(); + for (const w of writes.filter((w) => !w.namesUpdatedAt)) { + stamping.set(w.file, (stamping.get(w.file) ?? 0) + 1); + } + const offenders = [...stamping] + .filter(([file, n]) => n !== COACH_ACTIVITY[file]?.writes) + .map(([file, n]) => `${file}: ${n} stamping write(s)`); expect( - [...new Set(offenders)], - "a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL)", + offenders, + "a coachConversation write that is not activity must keep updatedAt (carry it, or write through raw SQL); a new activity write is listed with its count", ).toEqual([]); + // Every listed file is still there with its writes. + expect(Object.keys(COACH_ACTIVITY).filter((f) => !stamping.has(f))).toEqual( + [], + ); }); }); diff --git a/src/__tests__/background-reasoning-inventory.test.ts b/src/__tests__/background-reasoning-inventory.test.ts new file mode 100644 index 000000000..cafb8bf61 --- /dev/null +++ b/src/__tests__/background-reasoning-inventory.test.ts @@ -0,0 +1,128 @@ +/** + * v1.41 — which background calls may reason, frozen. + * + * Reasoning in the background is paid for every night, for every account, by + * nobody watching. So it is admitted where it changes the result a person + * reads (the daily briefing and the period narratives) and nowhere else: not + * on status cards, batch notes, derived scores, workout and reaction lines, + * nudges, document reads or OCR, which are routine, latency-bound and + * seed-reproducible. + * + * The only way a background call asks is the `reasoningJob` argument of the + * two chokepoints (`runStatusCompletion`, `runBriefingCompletion`). This test + * finds every place that sets it and holds the set, file by file and job by + * job, against the inventory below. A status card that started passing a job + * goes red here; so does a new caller nobody admitted. + * + * Its limit: it reads source text for a `reasoningJob:` property and the job + * literals beside it. A call that built the argument object elsewhere and + * spread it in would slip the matcher; the chokepoint signature is the only + * documented way in, and the wire test (`background-reasoning-wire.test.ts`) + * proves a call without the argument sends nothing. + * + * Mutation check: add `reasoningJob: "daily_briefing"` to + * `src/lib/insights/status-batch.ts`, or delete it from the narrative + * generator: either goes red. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { BACKGROUND_REASONING_JOBS } from "@/lib/ai/reasoning/levels"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** File → the jobs it may name, each with the reason it may. */ +const ADMITTED: Record = { + "lib/insights/comprehensive-generate.ts": { + jobs: ["daily_briefing"], + why: "The daily briefing's generation and its phrasing re-roll; the JSON and grounding repairs do not reason.", + }, + "app/api/insights/generate/route.ts": { + jobs: ["daily_briefing"], + why: "A briefing asked for by hand reasons like the scheduled one; its JSON and grounding repairs do not.", + }, + "lib/insights/narrative/period-narrative-generate.ts": { + jobs: ["period_narrative_month", "period_narrative_week"], + why: "The weekly and monthly narratives' first pass; the grounding repair does not reason.", + }, +}; + +/** The routine generators that must never name a job. */ +const NEVER = [ + "lib/insights/status-batch.ts", + "lib/insights/status-card-generation.ts", + "lib/insights/metric-status.ts", + "lib/insights/biomarker-status.ts", + "lib/insights/derived/derived-assessment-ai.ts", + "lib/jobs/workout-insight-generate.ts", +]; + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** `reasoningJob:` as a value, not the optional declaration `reasoningJob?:`. */ +const SETTER = /\breasoningJob\s*:(?!\s*BackgroundReasoningJob)/g; + +function scan(): Map }> { + const found = new Map }>(); + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); + for (const rel of files) { + const code = stripComments(readFileSync(join(SRC, rel), "utf8")); + const setters = code.match(SETTER)?.length ?? 0; + if (setters === 0) continue; + const jobs = new Set(); + for (const m of code.matchAll(SETTER)) { + const window = code.slice(m.index, m.index + 160); + for (const job of BACKGROUND_REASONING_JOBS) { + if (window.includes(`"${job}"`)) jobs.add(job); + } + } + found.set(rel, { setters, jobs }); + } + return found; +} + +describe("background reasoning inventory", () => { + const found = scan(); + + it("finds the admitted callers (an empty set is a failure, not a pass)", () => { + expect(found.size).toBeGreaterThanOrEqual(2); + }); + + it("only the admitted files name a job", () => { + expect([...found.keys()].sort()).toEqual(Object.keys(ADMITTED).sort()); + }); + + it.each(Object.entries(ADMITTED))( + "%s names exactly its admitted jobs", + (rel, { jobs }) => { + expect([...(found.get(rel)?.jobs ?? [])].sort()).toEqual( + [...jobs].sort(), + ); + }, + ); + + it.each(NEVER)("%s never reasons", (rel) => { + const code = stripComments(readFileSync(join(SRC, rel), "utf8")); + expect(code).not.toMatch(/\breasoningJob\b/); + expect(code).not.toMatch(/\breasoning\s*:/); + }); + + it("every admitted job is a known background job", () => { + for (const { jobs } of Object.values(ADMITTED)) { + for (const job of jobs) { + expect(BACKGROUND_REASONING_JOBS as readonly string[]).toContain(job); + } + } + }); +}); diff --git a/src/__tests__/coach-focus-ring-guard.test.ts b/src/__tests__/coach-focus-ring-guard.test.ts new file mode 100644 index 000000000..e51241121 --- /dev/null +++ b/src/__tests__/coach-focus-ring-guard.test.ts @@ -0,0 +1,56 @@ +/** + * Structural guard on keyboard focus in the Coach panel. + * + * The purple `--ring` is gone from every Coach control: a tap draws nothing, + * keyboard focus draws a solid ring in the neutral text-field focus tone + * (`--input-focus`). Two checks: + * + * 1. No class string under `coach-panel/` names the purple ring, border or + * outline on focus. The sweep must find a floor of neutral focus rings, + * so a matcher that stops matching fails instead of passing on nothing. + * 2. The controls built on the `Button` primitive inside an answer (the + * reply pills, Undo, Change) pass `COACH_FOCUS_RING`, which replaces the + * primitive's purple ring when the classes merge. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const DIR = join(__dirname, "..", "components", "insights", "coach-panel"); + +const SOURCES = readdirSync(DIR) + .filter((name) => /\.(tsx|ts)$/.test(name)) + .map((name) => ({ name, text: readFileSync(join(DIR, name), "utf8") })); + +const PURPLE_FOCUS = + /(?:focus|focus-visible|focus-within):(?:ring-ring|border-ring|outline-ring|ring-primary|border-primary)\b/g; + +describe("Coach focus ring", () => { + it("never names the purple ring on focus", () => { + const offenders = SOURCES.flatMap(({ name, text }) => + [...text.matchAll(PURPLE_FOCUS)].map((m) => `${name}: ${m[0]}`), + ); + expect(offenders).toEqual([]); + }); + + it("finds the neutral ring where it expects it", () => { + const neutral = SOURCES.reduce( + (n, { text }) => + n + (text.match(/focus-visible:ring-input-focus\b/g)?.length ?? 0), + 0, + ); + expect(neutral).toBeGreaterThanOrEqual(15); + }); + + it.each(["suggested-replies.tsx", "memory-note.tsx", "assumption-line.tsx"])( + "%s merges the neutral ring over the Button primitive", + (file) => { + const text = SOURCES.find((s) => s.name === file)?.text ?? ""; + expect(text).toMatch( + /import \{ COACH_FOCUS_RING \} from "\.\/focus-ring"/, + ); + expect(text).toMatch(/cn\([\s\S]*?COACH_FOCUS_RING,?\s*\)/); + }, + ); +}); diff --git a/src/__tests__/coach-stream-ios-compat.test.ts b/src/__tests__/coach-stream-ios-compat.test.ts index 8235fb064..2276ef55b 100644 --- a/src/__tests__/coach-stream-ios-compat.test.ts +++ b/src/__tests__/coach-stream-ios-compat.test.ts @@ -10,7 +10,8 @@ * 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`). + * (`step`, `result`, `followUps`, `clarification`, and since v1.41 + * `activity`, `memoryNote` with `note`, `planProposal` with `proposal`). * * 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 @@ -81,6 +82,9 @@ describe("Coach stream frames stay decodable by the shipped native client", () = "result", "followUps", "clarification", + "activity", + "memoryNote", + "planProposal", ]), ); expect( diff --git a/src/__tests__/coach-voice-guard.test.ts b/src/__tests__/coach-voice-guard.test.ts new file mode 100644 index 000000000..2e2475681 --- /dev/null +++ b/src/__tests__/coach-voice-guard.test.ts @@ -0,0 +1,149 @@ +/** + * v1.41 — the Coach's voice, held in the prompt. + * + * The voice section (`buildCoachVoiceBlock`) is the one statement of how a + * Coach answer is shaped: the answer first, one carrying number, the + * assumption in one line, at most one next step, 40-120 words, and a closed + * list of filler it never writes. A prompt rewrite that drops a rule, or an + * example that models the opposite (a closing question, two next steps, a + * filler), would quietly teach the model the old habits back. This guard + * pins both bodies to the rules, and every few-shot example to the grader + * that scores live answers. + * + * Each check is a function run once on the real prompt and once on a prompt + * broken on purpose, so the guard is shown to fail. + */ +import { describe, expect, it } from "vitest"; + +import { + COACH_ANSWER_WORDS, + COACH_FILLER_PHRASES, + buildCoachVoiceBlock, + getCoachSystemPrompt, +} from "@/lib/ai/coach/system-prompt"; +import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; +import type { Locale } from "@/lib/i18n/config"; + +const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"]; + +/** The rules a voice section must state; `[]` when it states them all. */ +function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] { + const missing: string[] = []; + const band = COACH_ANSWER_WORDS.standard; + const header = + lang === "de" ? "TON UND ANTWORTAUFBAU" : "VOICE AND ANSWER SHAPE"; + if (!prompt.includes(header)) missing.push("header"); + if (!prompt.includes(`${band.min}-${band.max}`)) missing.push("length band"); + const rules = + lang === "de" + ? [ + /erste Satz beantwortet die Frage/, + /Eine tragende Zahl/, + /Angenommen: letzte/, + /höchstens EINEM konkreten nächsten Schritt/, + /Nie eine Frage am Ende/, + /keine Emojis/, + ] + : [ + /first sentence answers the question/, + /One carrying number/, + /Assumed: last 30 days/, + /at most ONE concrete next step/, + /Never a question at the end/, + /no emojis/, + ]; + for (const rule of rules) if (!rule.test(prompt)) missing.push(rule.source); + for (const phrase of COACH_FILLER_PHRASES[lang]) { + if (!prompt.includes(`"${phrase}"`)) missing.push(`filler ${phrase}`); + } + return missing; +} + +/** The COACH lines of the few-shot examples. */ +function exampleAnswers(prompt: string): string[] { + const answers: string[] = []; + for (const match of prompt.matchAll(/([\s\S]*?)<\/example>/g)) { + const coach = match[1].split(/^COACH:\s*/m)[1]; + if (coach) answers.push(coach.trim()); + } + return answers; +} + +/** Examples that break the voice; `[]` when every one keeps it. */ +function examplesOffVoice(prompt: string): string[] { + return exampleAnswers(prompt).filter((answer) => !gradeVoice(answer).passed); +} + +describe("coach voice guard", () => { + it.each(["en", "de"] as const)( + "the %s body states every voice rule and filler", + (lang) => { + const prompt = getCoachSystemPrompt(lang); + expect(missingVoiceRules(prompt, lang)).toEqual([]); + // Broken on purpose: one filler dropped from the list. + const broken = prompt.replaceAll( + `"${COACH_FILLER_PHRASES[lang][0]}"`, + "", + ); + expect(missingVoiceRules(broken, lang)).not.toEqual([]); + }, + ); + + it.each(LOCALES)("the %s prompt carries the voice section", (locale) => { + expect(getCoachSystemPrompt(locale)).toContain( + buildCoachVoiceBlock(locale), + ); + }); + + it.each(["en", "de"] as const)( + "every %s example answer keeps the voice", + (lang) => { + const prompt = getCoachSystemPrompt(lang); + expect(exampleAnswers(prompt).length).toBeGreaterThanOrEqual(5); + expect(examplesOffVoice(prompt)).toEqual([]); + // Broken on purpose: an example that ends on a habitual question. + const broken = prompt.replace( + /([\s\S]*?COACH:[\s\S]*?)(\n(?:---KEYVALUES---|<\/example>))/, + "$1 How does that sound to you?$2", + ); + expect(examplesOffVoice(broken)).not.toEqual([]); + }, + ); + + it("keeps no length rule from before the voice section", () => { + for (const lang of ["en", "de"] as const) { + const prompt = getCoachSystemPrompt(lang, { + ...DEFAULT_COACH_PREFS, + verbosity: "detailed", + }); + expect(prompt).not.toMatch(/60-180/); + expect(prompt).not.toMatch(/180-250/); + expect(prompt).not.toMatch(/~90/); + } + }); + + it("states the brief and detailed lengths from the same numbers", () => { + const brief = getCoachSystemPrompt("en", { + ...DEFAULT_COACH_PREFS, + verbosity: "brief", + }); + expect(brief).toContain( + `${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} words`, + ); + const detailed = getCoachSystemPrompt("de", { + ...DEFAULT_COACH_PREFS, + verbosity: "detailed", + }); + expect(detailed).toContain(`bis ${COACH_ANSWER_WORDS.detailed.max} Wörter`); + }); + + it("asks a confidence question only on an explicit plan request", () => { + expect(getCoachSystemPrompt("en")).toMatch( + /Confidence ruler, only on an explicit plan request/, + ); + expect(getCoachSystemPrompt("de")).toMatch( + /Konfidenz-Skala, nur bei ausdrücklicher Plan-Bitte/, + ); + }); +}); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 34f750ae7..a429ecb1a 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [ }, { file: "components/settings/coach-memory-section.tsx", - destroys: "one remembered Coach fact, or every one of them", + destroys: "every remembered Coach fact at once", recovery: "tombstoned-no-restore", - confirm: ["ConfirmButton", "AlertDialog"], + confirm: ["AlertDialog"], + }, + { + file: "components/insights/coach-panel/use-coach-facts.ts", + destroys: "one remembered Coach fact", + triggers: [ + "components/settings/coach-memory-section.tsx", + "components/insights/coach-panel/memory-note.tsx", + ], + recovery: "tombstoned-no-restore", + confirm: ["ConfirmButton"], + unconfirmed: [ + { + control: "Undo on the memory note under an answer — memory-note.tsx", + reason: + "Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.", + }, + ], }, { file: "hooks/use-coach-reminders.ts", @@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [ { file: "hooks/use-coach-plans.ts", destroys: "a Coach plan and the record of how it went", - triggers: [ - "app/coach/plans/page.tsx", - "components/insights/coach-panel/plan-proposal-card.tsx", - ], + triggers: ["app/coach/plans/page.tsx"], recovery: "tombstoned-no-restore", confirm: ["ConfirmButton"], unconfirmed: [ { - control: - "decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx", + control: "decline a PROPOSED plan — app/coach/plans/page.tsx", reason: "Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.", }, diff --git a/src/__tests__/memory-block-egress-guard.test.ts b/src/__tests__/memory-block-egress-guard.test.ts new file mode 100644 index 000000000..a874be864 --- /dev/null +++ b/src/__tests__/memory-block-egress-guard.test.ts @@ -0,0 +1,158 @@ +/** + * v1.41 — what the person told the Coach leaves the machine only when the + * turn may send to its providers. + * + * The memory block (`WHAT YOU KNOW ABOUT THIS PERSON`) carries facts, plans + * and reminders the person wrote; the briefing's plan lines carry plan + * targets. Both are built, not built-then-dropped: the egress or capability + * check runs first and nothing is read on a refusal. Behaviour is pinned in + * `memory/__tests__/context-block.test.ts` and `plan-progress.test.ts`; this + * guard pins the structure those tests rest on: + * + * 1. Inside each builder, the check comes before the first database read. + * 2. The builders are called only from the files that run after the turn's + * own egress check (the Coach turn) or behind the briefing capability (the + * briefing generation), and a Coach-turn caller hands over the chain so + * the wire check runs for exactly those providers. + * 3. No `annotate()` in the memory modules names a text-bearing value. + * + * Every check is also run against a source broken on purpose. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const MEMORY = "lib/ai/coach/memory"; + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +} + +/** The body of `export async function (`, up to the next top-level `}`. */ +function functionBody(source: string, name: string): string { + const start = source.search( + new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`), + ); + if (start === -1) return ""; + const end = source.indexOf("\n}\n", start); + return source.slice(start, end === -1 ? undefined : end); +} + +/** Whether `check` appears before the first database read in `body`. */ +function checksBeforeRead(body: string, check: RegExp): boolean { + const checkAt = body.search(check); + const readAt = body.search( + /\bprisma\.|\bload(?:Facts|Plans|Reminders)\(|takePendingProposal\(/, + ); + return checkAt !== -1 && readAt !== -1 && checkAt < readAt; +} + +describe("memory block egress guard", () => { + const block = functionBody( + read(`${MEMORY}/context-block.ts`), + "buildMemoryContextBlock", + ); + const lines = functionBody( + read(`${MEMORY}/plan-progress.ts`), + "buildPlanProgressLines", + ); + + it("the memory block checks egress before it reads anything", () => { + expect(block.length).toBeGreaterThan(200); + expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true); + const helper = read(`${MEMORY}/context-block.ts`); + expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/); + expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/); + // Broken on purpose: the check moved below the reads. + const broken = block.replace(/if \(!\(await egressAllowed\(args\)\)\)/, ""); + expect(checksBeforeRead(broken, /egressAllowed\(/)).toBe(false); + }); + + it("the briefing's plan lines check the briefing capability before any read", () => { + expect(lines.length).toBeGreaterThan(200); + expect( + checksBeforeRead(lines, /aiCapabilityForRecord\(userId, "briefing"\)/), + ).toBe(true); + const broken = lines.replace( + /aiCapabilityForRecord\(userId, "briefing"\)/, + "", + ); + expect( + checksBeforeRead(broken, /aiCapabilityForRecord\(userId, "briefing"\)/), + ).toBe(false); + }); + + /** + * Files that may build the memory block: the Coach turn, after its own + * capability and consent checks (`turn/pipeline.ts` rechecks before it + * runs the model). The contract re-exports it; nothing else calls it. + */ + const BLOCK_CALLERS = new Set([ + "lib/ai/coach/turn/model.ts", + "lib/ai/coach/turn/context.ts", + ]); + /** Files that may build the briefing's plan lines. */ + const LINE_CALLERS = new Set([ + "lib/insights/comprehensive-generate.ts", + "lib/insights/briefing-provider.ts", + ]); + + function callers(name: string): string[] { + const call = new RegExp(`\\b${name}\\(`); + return sourceFiles() + .filter((rel) => !rel.startsWith(`${MEMORY}/`)) + .filter((rel) => call.test(read(rel))); + } + + it("only the Coach turn builds the memory block, and it names its providers", () => { + const found = callers("buildMemoryContextBlock"); + for (const rel of found) { + expect(BLOCK_CALLERS.has(rel), rel).toBe(true); + expect(read(rel), `${rel} hands over the chain`).toMatch( + /buildMemoryContextBlock\(\{[\s\S]{0,400}providerTypes/, + ); + } + // The matcher is live: it finds the definition it guards. + const definitions = sourceFiles().filter((rel) => + /export async function buildMemoryContextBlock\(/.test(read(rel)), + ); + expect(definitions).toEqual([`${MEMORY}/context-block.ts`]); + }); + + it("only the briefing builds the plan lines", () => { + for (const rel of callers("buildPlanProgressLines")) { + expect(LINE_CALLERS.has(rel), rel).toBe(true); + } + }); + + it("no memory annotation names a text-bearing value", () => { + const TEXT = + /\b(?:fact|text|ifCue|thenAction|target|recalled|note\.fact|userMessage|content)\s*[,:}]/; + const offending: string[] = []; + const files = walkSourceFiles(join(SRC, MEMORY), { floor: 5 }).filter( + (p) => !p.includes("__tests__"), + ); + for (const rel of files) { + const source = read(`${MEMORY}/${rel}`); + for (const match of source.matchAll(/annotate\(\{[\s\S]*?\}\);/g)) { + const meta = match[0].split("meta:")[1] ?? ""; + if (TEXT.test(meta)) offending.push(`${rel}: ${match[0]}`); + } + } + expect(offending).toEqual([]); + // Broken on purpose: a meta that carries the fact. + const planted = `annotate({ action: { name: "x" }, meta: { fact: call.fact } });`; + expect(TEXT.test(planted.split("meta:")[1])).toBe(true); + }); +}); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index bd9e68c89..64fb05022 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -190,9 +190,6 @@ const PINNED_AFFORDANCES: Record< "src/components/insights/coach-panel/history-rail.tsx": { "toast.success": 1, }, - "src/components/insights/coach-panel/plan-proposal-card.tsx": { - "text-success": 1, - }, "src/components/insights/coach-panel/reminder-suggestion-card.tsx": { "text-success": 1, }, diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 296434789..4f6362757 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -390,7 +390,7 @@ const UNIT_LITERAL_ALLOWLIST: Record< "the reply checker's list of unit tokens it recognises in model prose", }, "lib/ai/coach/eval/golden-cases.ts": { - lines: 3, + lines: 5, reason: "evaluation fixtures: scripted replies and ideal answers", }, "lib/ai/coach/eval/red-team.ts": { diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 1160e4ee8..3e314e53d 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -102,6 +102,11 @@ export const AI_ROUTES: Readonly> = { capabilities: ["coach"], why: "POST streams a Coach reply from a model. The history GET in the same file asks nothing.", }, + "src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts": { + kind: "mixed", + capabilities: ["coach"], + why: "The trail's reasoning text is model-written and served only while the Coach's text may be shown; otherwise 200 with trail null and the ai state.", + }, "src/app/api/insights/generate/route.ts": { kind: "action", capabilities: ["briefing"], 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 349245bea..20d98d73a 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -332,7 +332,9 @@ const EXEMPT_ROUTES: ReadonlyArray = [ // being available (the GDPR reason), and a timestamp on one's own row is // data too. "src/app/api/insights/coach/seen/route.ts", - "src/app/api/insights/coach/facts/route.ts", + // `insights/coach/facts/route.ts` is not listed, by the rule below: it + // lists and erases without the Coach gate but keeps it on POST (the + // remember button saves to the Coach's memory, which is Coach use). "src/app/api/insights/coach/facts/[id]/route.ts", // The stored plans list. Its sibling `[id]` route keeps the Coach gate on // PATCH (confirming a proposal is Coach use) and erases without it. diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 41ad2bf8c..89f60dd8b 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -46,6 +46,8 @@ const ALL_ON = { assistantBriefingEnabled: true, assistantInsightStatusEnabled: true, assistantDocumentAiEnabled: true, + aiReasoningEnabled: true, + aiReasoningMaxEffort: "high", }; beforeEach(() => { @@ -85,6 +87,34 @@ describe("GET /api/admin/settings/assistant-flags", () => { expect(body.data.raw.assistantEnabled).toBe(true); expect(body.data.resolved.coach).toBe(true); }); + + it("publishes the reasoning controls, defaulted on and uncapped", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/assistant-flags"), + ); + const body = (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; + expect(body.data.reasoning).toEqual({ enabled: true, maxEffort: "high" }); + }); + + it("reads a stored cap and an off switch back", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + ...ALL_ON, + aiReasoningEnabled: false, + aiReasoningMaxEffort: "low", + } as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/assistant-flags"), + ); + const body = (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; + expect(body.data.reasoning).toEqual({ enabled: false, maxEffort: "low" }); + }); }); describe("PUT /api/admin/settings/assistant-flags", () => { @@ -141,6 +171,46 @@ describe("PUT /api/admin/settings/assistant-flags", () => { expect(body.data.resolved.documentAi).toBe(false); }); + it("switches reasoning off and caps it, with an audit row", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + ...ALL_ON, + aiReasoningEnabled: false, + aiReasoningMaxEffort: "medium", + } as never); + + const res = await PUT( + putReq({ aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }), + ); + expect(res.status).toBe(200); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect(upsert.update).toEqual({ + aiReasoningEnabled: false, + aiReasoningMaxEffort: "medium", + }); + const { auditLog } = await import("@/lib/auth/audit"); + expect(vi.mocked(auditLog).mock.calls[0][1]).toMatchObject({ + details: { aiReasoningEnabled: false, aiReasoningMaxEffort: "medium" }, + }); + const body = (await res.json()) as { + data: { reasoning: { enabled: boolean; maxEffort: string } }; + }; + expect(body.data.reasoning).toEqual({ + enabled: false, + maxEffort: "medium", + }); + }); + + it.each(["off", "xhigh", "", 3])( + "refuses %s as the highest level", + async (value) => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PUT(putReq({ aiReasoningMaxEffort: value })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }, + ); + it("rejects an empty body", async () => { vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); const res = await PUT(putReq({})); diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index af5c45ec5..8190a70ba 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -28,6 +28,14 @@ * so the admin UI can render the master vs sub-flag distinction * visually. * + * v1.41 — the same route carries the operator's two reasoning controls: + * `aiReasoningEnabled` (off wins over every person's Coach setting and every + * background job) and `aiReasoningMaxEffort` (`low` | `medium` | `high`, the + * highest level any call may take). Echoed as `reasoning: { enabled, + * maxEffort }`. They sit beside the assistant switches rather than in them: + * switching reasoning off stops no AI capability, it only stops the model + * thinking longer before it answers. + * * `requireAdmin()` gates the route — non-admins get 403. */ import type { NextRequest } from "next/server"; @@ -45,6 +53,8 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { resolveAssistantFlags } from "@/lib/feature-flags"; import { annotate } from "@/lib/logging/context"; +import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels"; +import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve"; const assistantFlagsSchema = z .object({ @@ -53,6 +63,8 @@ const assistantFlagsSchema = z assistantBriefingEnabled: z.boolean().optional(), assistantInsightStatusEnabled: z.boolean().optional(), assistantDocumentAiEnabled: z.boolean().optional(), + aiReasoningEnabled: z.boolean().optional(), + aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(), }) .strict(); @@ -62,8 +74,20 @@ type AssistantFlagsRow = { assistantBriefingEnabled: boolean; assistantInsightStatusEnabled: boolean; assistantDocumentAiEnabled: boolean; + aiReasoningEnabled: boolean; + aiReasoningMaxEffort: string; }; +const ROW_SELECT = { + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantDocumentAiEnabled: true, + aiReasoningEnabled: true, + aiReasoningMaxEffort: true, +} as const; + function buildResponseShape(row: AssistantFlagsRow) { const resolved = resolveAssistantFlags({ enabled: row.assistantEnabled, @@ -81,6 +105,10 @@ function buildResponseShape(row: AssistantFlagsRow) { assistantDocumentAiEnabled: row.assistantDocumentAiEnabled, }, resolved, + reasoning: { + enabled: row.aiReasoningEnabled, + maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort), + }, }; } @@ -92,13 +120,7 @@ export const GET = apiHandler(async () => { const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" }, - select: { - assistantEnabled: true, - assistantCoachEnabled: true, - assistantBriefingEnabled: true, - assistantInsightStatusEnabled: true, - assistantDocumentAiEnabled: true, - }, + select: ROW_SELECT, }); const row: AssistantFlagsRow = { @@ -108,6 +130,8 @@ export const GET = apiHandler(async () => { assistantInsightStatusEnabled: settings?.assistantInsightStatusEnabled ?? true, assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true, + aiReasoningEnabled: settings?.aiReasoningEnabled ?? true, + aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high", }; return apiSuccess(buildResponseShape(row)); @@ -128,14 +152,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } + // Field by field from the parsed body; the schema is strict, so every key + // here is one the route owns. const updates: Partial = {}; - const auditDetails: Record = {}; for (const [key, value] of Object.entries(parsed.data)) { - if (typeof value === "boolean") { - (updates as Record)[key] = value; - auditDetails[key] = value; + if (value !== undefined) { + (updates as Record)[key] = value; } } + const auditDetails: Record = { ...updates }; if (Object.keys(updates).length === 0) { return apiError("No valid fields", 422); @@ -145,6 +170,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { where: { id: "singleton" }, update: updates, create: { id: "singleton", ...updates }, + select: ROW_SELECT, }); await auditLog("admin.settings.assistant-flags.update", { @@ -159,6 +185,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { assistantBriefingEnabled: settings.assistantBriefingEnabled, assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled, assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled, + aiReasoningEnabled: settings.aiReasoningEnabled, + aiReasoningMaxEffort: settings.aiReasoningMaxEffort, }; return apiSuccess(buildResponseShape(row)); diff --git a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts index 0a5592c0f..b87a157ee 100644 --- a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts @@ -172,3 +172,50 @@ describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => { expect(prisma.user.updateMany).not.toHaveBeenCalled(); }); }); + +describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => { + it("stores the chosen level and names it on the wide event", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({ + updatedAt: new Date("2026-10-06T11:00:00.000Z"), + } as never); + const { annotate } = await import("@/lib/logging/context"); + + const res = await (PUT as (r: Request) => Promise)( + mkPut({ reasoning: "high" }), + ); + expect(res.status).toBe(200); + const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { + data: { coachPrefsJson: { reasoning?: string } }; + }; + expect(call.data.coachPrefsJson.reasoning).toBe("high"); + const env = (await res.json()) as { data: { reasoning?: string } }; + expect(env.data.reasoning).toBe("high"); + const meta = vi + .mocked(annotate) + .mock.calls.map((c) => c[0]) + .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; + expect(meta).toMatchObject({ reasoning: "high" }); + }); + + it("reads an absent level as the default on the wide event", async () => { + vi.mocked(prisma.user.update).mockResolvedValue({ + updatedAt: new Date("2026-10-06T11:00:00.000Z"), + } as never); + const { annotate } = await import("@/lib/logging/context"); + + await (PUT as (r: Request) => Promise)(mkPut({ tone: "warm" })); + const meta = vi + .mocked(annotate) + .mock.calls.map((c) => c[0]) + .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; + expect(meta).toMatchObject({ reasoning: "medium" }); + }); + + it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => { + const res = await (PUT as (r: Request) => Promise)( + mkPut({ reasoning: value }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index 4e1bf8593..7624b4d5e 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -31,6 +31,7 @@ import { } from "@/lib/optimistic-lock"; import { coachPrefsSchema, + coachReasoningLevel, parseCoachPrefs, } from "@/lib/validations/coach-prefs"; @@ -130,6 +131,10 @@ export const PUT = apiHandler(async (req: Request) => { // v1.7.0 — `null` when the caller left `dataClusters` absent // (legacy defaults stay in force); otherwise the selected count. clusterCount: parsed.data.dataClusters?.length ?? null, + // v1.41 — the stored thinking depth, absent read as the default. The + // operator's switch and cap apply on top of it at turn time; the + // resolved value is `coachReasoning` on GET /api/auth/me. + reasoning: coachReasoningLevel(parsed.data), }, }); return apiSuccess({ ...parsed.data, updatedAt: guarded.updatedAt }); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index e150408eb..4967136e2 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -72,6 +72,8 @@ import { } from "@/lib/sharing/module-disclosure"; import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; import { loadAiCapabilities } from "@/lib/ai/capabilities/load"; +import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls"; +import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; export const dynamic = "force-dynamic"; @@ -143,6 +145,7 @@ export const GET = apiHandler(async () => { moduleAvailability, onboardingRow, ai, + coachReasoning, ] = await Promise.all([ // The actor's own row is already in hand; a switched session needs the // record's `gender`, which is the column the cycle gate derives from. @@ -206,6 +209,16 @@ export const GET = apiHandler(async () => { sections, recordKind, }), + // v1.41 — the Coach's thinking depth, resolved: the caller's own + // preference (Coach preferences belong to the person, like every other + // display preference here) against the operator's switch and cap and the + // record's provider. The provider read is the memoised presence probe the + // `ai` block above already ran. + loadCoachReasoningState({ + prefs: parseCoachPrefs(user.coachPrefsJson), + recordId, + authority: aiAuthority, + }), ]); // The masking step, and the one place the record scoping is narrowed rather @@ -397,6 +410,14 @@ export const GET = apiHandler(async () => { // the RECORD and masked to the grant, like `modules`. Clients render from // it and never recompute it; data never depends on it. ai, + // v1.41 — the Coach's thinking depth, resolved on the server so the web + // settings and the native client show the same answer: `level` is what a + // turn runs at now, `preference` what the person picked, `maxLevel` the + // highest option the settings may offer (`off` when the operator switched + // reasoning off), `available` whether reasoning can run at all, + // `offIsReal` whether the lowest option is a real off or "Minimal", and + // `source` which layer decided. Clients never recompute it. + coachReasoning, // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, // `active` is the record this session is inside (null when it is in its // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index fc368aa71..faf7f89cb 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -36,6 +36,7 @@ import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { clampReviewDays } from "@/lib/ai/coach/memory/propose-plan"; import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/coach-checkin-intents"; import { coachPlanPatchSchema } from "@/lib/validations/coach-plan"; @@ -102,15 +103,25 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { // +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read // the current row first so an explicitly-pinned review date is never // clobbered; the read stays owner-scoped, so it leaks no cross-account state. + // v1.41 — a plan the Coach proposed in a turn carries its review window as + // the distance from the proposal to its `reviewDate`; taking it on here + // counts that window from now, exactly like the tap under the answer + // (`decidePlanProposal`). if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) { const current = await prisma.coachPlan.findFirst({ where: { id, userId: user.id, deletedAt: null }, - select: { reviewDate: true }, + select: { reviewDate: true, status: true, createdAt: true }, }); if (current && current.reviewDate === null) { data.reviewDate = new Date( Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000, ); + } else if (current?.reviewDate && current.status === "proposed") { + const days = clampReviewDays( + (current.reviewDate.getTime() - current.createdAt.getTime()) / + 86_400_000, + ); + data.reviewDate = new Date(Date.now() + days * 86_400_000); } } diff --git a/src/app/api/coach/plans/route.ts b/src/app/api/coach/plans/route.ts index 7c656ed40..c379d3b37 100644 --- a/src/app/api/coach/plans/route.ts +++ b/src/app/api/coach/plans/route.ts @@ -5,7 +5,10 @@ * Coach proposes. A plan is an "if-then" implementation intention tied to one * metric, with an optional target. The Coach extractor writes a plan as * `status: "proposed"`; only the user-facing PATCH (`/api/coach/plans/[id]`) - * activates it. There is no silent self-edit of the user's plan set. + * activates it. There is no silent self-edit of the user's plan set. A plan + * the Coach proposes in a turn (`propose_plan`, v1.41) exists only once the + * answer carrying it is stored (`memory/turn-writes.ts`), so a turn that was + * blocked, failed or abandoned has nothing here to list. * * Ownership: every query is scoped `where: { userId, ... }`, so a caller can * only ever see their own plans. The free-text fields (if-cue, then-action, diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts new file mode 100644 index 000000000..e7eb13654 --- /dev/null +++ b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/__tests__/route.test.ts @@ -0,0 +1,175 @@ +/** + * v1.41 — the trail route: owner-narrowed, 404 for anything else, the + * model-written text only while the Coach's text may be shown, and the + * person's own facts (recalled, proposed) always, minus medication facts + * while the medications module is off. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +const { + requireAuth, + readMessageTrail, + aiCapabilityToServe, + isModuleEnabled, + findMany, +} = vi.hoisted(() => ({ + requireAuth: vi.fn(), + readMessageTrail: vi.fn(), + aiCapabilityToServe: vi.fn(), + isModuleEnabled: vi.fn(), + findMany: 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/ai/coach/persistence", () => ({ readMessageTrail })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe })); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled })); +vi.mock("@/lib/db", () => ({ prisma: { coachFact: { findMany } } })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b), +})); + +import { GET } from "../route"; + +const USER_ID = "user-1"; +const TRAIL = { + entries: [ + { + id: "a1", + title: "Weighing the two weeks", + text: "Looked at sleep first.", + }, + ], +}; + +function call(id = "c1", messageId = "m1") { + return GET( + new NextRequest( + `http://localhost/api/insights/chat/${id}/messages/${messageId}/trail`, + ), + { params: Promise.resolve({ id, messageId }) }, + ) as Promise; +} + +beforeEach(() => { + vi.clearAllMocks(); + requireAuth.mockResolvedValue({ user: { id: USER_ID } }); + aiCapabilityToServe.mockResolvedValue({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + isModuleEnabled.mockResolvedValue(true); + findMany.mockResolvedValue([]); +}); + +const UNAVAILABLE = { + available: false, + reason: "disabled_by_user", + onDeviceAllowed: false, +}; + +const WITH_FACTS = { + ...TRAIL, + recalled: ["Prefers walks after dinner", "Takes Mounjaro since May"], + proposal: { + proposalId: "mp_1", + category: "condition", + fact: "Has asthma", + }, +}; + +describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => { + it("reads under the caller's own id and the path ids only", async () => { + readMessageTrail.mockResolvedValue({ trail: TRAIL }); + const response = await call("c1", "m1"); + expect(response.status).toBe(200); + expect(readMessageTrail).toHaveBeenCalledWith(USER_ID, "c1", "m1"); + // Requires a session or a full-access token, like every Coach read. + expect(requireAuth).toHaveBeenCalledWith(); + const body = await response.json(); + expect(body.data.trail).toEqual(TRAIL); + expect(body.data.ai.available).toBe(true); + }); + + it("answers 404 for a message the caller does not own", async () => { + readMessageTrail.mockResolvedValue(null); + const response = await call(); + expect(response.status).toBe(404); + expect(aiCapabilityToServe).not.toHaveBeenCalled(); + }); + + it("serves no model text while the Coach's text may not be shown", async () => { + readMessageTrail.mockResolvedValue({ trail: TRAIL }); + aiCapabilityToServe.mockResolvedValue({ + available: false, + reason: "disabled_by_user", + onDeviceAllowed: false, + }); + const response = await call(); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data.trail).toBeNull(); + expect(body.data.ai.available).toBe(false); + expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach"); + }); + + it("serves the person's own facts while the model text is withheld", async () => { + readMessageTrail.mockResolvedValue({ trail: WITH_FACTS }); + aiCapabilityToServe.mockResolvedValue(UNAVAILABLE); + const body = await (await call()).json(); + expect(body.data.trail).toEqual({ + entries: [], + recalled: WITH_FACTS.recalled, + proposal: WITH_FACTS.proposal, + }); + expect(body.data.ai.available).toBe(false); + }); + + it("withholds medication facts while the medications module is off", async () => { + readMessageTrail.mockResolvedValue({ + trail: { + ...WITH_FACTS, + recalled: [...WITH_FACTS.recalled, "Morning pill with coffee"], + proposal: { + proposalId: "mp_1", + category: "medication", + fact: "Takes ramipril", + }, + }, + }); + isModuleEnabled.mockResolvedValue(false); + // A stored fact filed under medication whose words the lexicon misses. + findMany.mockResolvedValue([ + { factEncrypted: new TextEncoder().encode("Morning pill with coffee") }, + ]); + const body = await (await call()).json(); + expect(isModuleEnabled).toHaveBeenCalledWith(USER_ID, "medications"); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: USER_ID, category: "medication" }, + }), + ); + expect(body.data.trail).toEqual({ + entries: TRAIL.entries, + recalled: ["Prefers walks after dinner"], + }); + }); + + it("answers a message without a trail with null", async () => { + readMessageTrail.mockResolvedValue({ trail: null }); + const body = await (await call()).json(); + expect(body.data.trail).toBeNull(); + }); +}); diff --git a/src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts new file mode 100644 index 000000000..8d3ccb6bc --- /dev/null +++ b/src/app/api/insights/chat/[id]/messages/[messageId]/trail/route.ts @@ -0,0 +1,85 @@ +/** + * GET /api/insights/chat/[id]/messages/[messageId]/trail — the text of one + * assistant message's trail (v1.41), decrypted for its owner: the screened + * reasoning titles and summaries and checkpoint sentences by trail entry, + * the facts the turn recalled, and a fact proposal it offered. The structure + * of the trail (phases, statuses, counts) is plaintext on the message's + * `metricSource.activity`; only the text is here, read lazily so a long + * thread does not carry every trail. + * + * Narrowed exactly like `…/results`: the message must sit in a conversation + * the caller owns, and a foreign or unknown id is 404, never 403. A session + * or a full-access token reads it; a narrow token is refused by + * `requireAuth()` like on every Coach read. + * + * A mixed read, split by who wrote what. The entries' text is model-written, + * so it is served only while the Coach's text may be shown for the record + * (`aiCapabilityToServe`); when it may not, `entries` is empty, the `ai` + * state rides beside it, and the client keeps the trail's catalog labels. + * The recalled facts and the proposal's fact are the person's own data and + * are served to the owner whatever the capability, like the memory list: + * only the medications module gates them, at read time, the way `…/results` + * withholds a table whose module is off. `trail` is null when nothing is + * left to serve. + */ +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 { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { readMessageTrail } from "@/lib/ai/coach/persistence"; +import { withholdMedicationFacts } from "@/lib/ai/coach/memory/trail-read"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import type { CoachTrail } from "@/lib/ai/coach/types"; + +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 read = await readMessageTrail(userId, id, messageId); + if (!read) return apiError("coach.message.notFound", 404); + + const ai = await aiCapabilityToServe(userId, "coach"); + const stored = read.trail; + let trail: CoachTrail | null = null; + let withheld = 0; + if (stored) { + const medicationsOn = await isModuleEnabled(userId, "medications"); + const facts = medicationsOn + ? { recalled: stored.recalled, proposal: stored.proposal, withheld: 0 } + : await withholdMedicationFacts(userId, stored); + withheld = facts.withheld; + const entries = ai.available ? stored.entries : []; + const recalled = + facts.recalled && facts.recalled.length > 0 ? facts.recalled : undefined; + if (entries.length > 0 || recalled || facts.proposal) { + trail = { + entries, + ...(recalled ? { recalled } : {}), + ...(facts.proposal ? { proposal: facts.proposal } : {}), + }; + } + } + + annotate({ + action: { name: "insights.coach.trail.read" }, + meta: { + conversationId: id, + entries: trail?.entries.length ?? 0, + recalled: trail?.recalled?.length ?? 0, + withheld, + served: ai.available, + }, + }); + + return apiSuccess({ trail, ai }); +}); + +export const dynamic = "force-dynamic"; diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap index 66e4f6e98..b9570c8ea 100644 --- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap +++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap @@ -19,6 +19,9 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -26,7 +29,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -50,10 +53,10 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -64,12 +67,33 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "appendMessage", "content": "Your systolic averaged 128 mmHg and diastolic 82 mmHg this month.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -100,6 +124,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -107,15 +132,28 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -162,6 +200,16 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -220,6 +268,9 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -227,7 +278,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -251,10 +302,10 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -265,7 +316,18 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -281,6 +343,16 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure "call": "appendMessage", "content": "Your systolic averaged 128 mmHg, up from […] mmHg last month.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -312,6 +384,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -319,15 +392,28 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -374,6 +460,16 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -433,6 +529,9 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -440,7 +539,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric ], }, { - "amount": 1800, + "amount": 12889, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -464,10 +563,10 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -478,7 +577,18 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11053, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -508,6 +618,16 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric "call": "appendMessage", "content": "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.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 42, ], @@ -536,6 +656,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -543,15 +664,28 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "I ", "type": "token", @@ -698,6 +832,16 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 42, ], @@ -754,6 +898,9 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -805,6 +952,36 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot "call": "appendMessage", "content": "Your systolic averaged 128 mmHg over the window.", "metricSource": { + "activity": [ + { + "count": 1, + "durationMs": 0, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "done", + "stepRef": "s1", + }, + { + "durationMs": 0, + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "done", + }, + { + "id": "a3", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -839,6 +1016,7 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -846,9 +1024,11 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, @@ -867,6 +1047,67 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot }, "type": "step", }, + { + "activity": { + "count": 1, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "running", + "stepRef": "s1", + }, + "type": "activity", + }, + { + "activity": { + "count": 1, + "durationMs": 0, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "done", + "stepRef": "s1", + }, + "type": "activity", + }, + { + "activity": { + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "running", + }, + "type": "activity", + }, + { + "activity": { + "durationMs": 0, + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "done", + }, + "type": "activity", + }, + { + "activity": { + "id": "a3", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -901,6 +1142,36 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot }, { "metricSource": { + "activity": [ + { + "count": 1, + "durationMs": 0, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "done", + "stepRef": "s1", + }, + { + "durationMs": 0, + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "done", + }, + { + "id": "a3", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -1080,6 +1351,9 @@ exports[`coach chat golden transcripts > 06 budget exceeded 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1131,6 +1405,9 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] = { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1138,7 +1415,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] = ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1162,10 +1439,10 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] = "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 0, @@ -1176,7 +1453,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] = "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 12891, }, { "call": "annotate", @@ -1220,6 +1497,9 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1227,7 +1507,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1251,10 +1531,10 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 0, @@ -1265,7 +1545,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 12891, }, { "call": "annotate", @@ -1309,6 +1589,9 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1316,7 +1599,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1340,10 +1623,10 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 0, @@ -1354,7 +1637,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 12891, }, { "call": "annotate", @@ -1398,6 +1681,9 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1 { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1405,7 +1691,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1 ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1429,10 +1715,10 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1 "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 0, @@ -1443,7 +1729,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1 "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 12891, }, { "call": "annotate", @@ -1488,6 +1774,9 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1495,7 +1784,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1519,10 +1808,10 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 0, @@ -1533,7 +1822,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 12891, }, { "call": "appendMessage", @@ -1584,6 +1873,9 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1591,7 +1883,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an ], }, { - "amount": 1800, + "amount": 12892, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1615,10 +1907,10 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -1629,7 +1921,18 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11056, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -1651,6 +1954,16 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an "call": "appendMessage", "content": "I can't suggest a specific medication dose or a change to one — dose decisions belong with your prescribing clinician. I can help you read what your own data shows around your medication; ask me about that and I'll stay grounded in your numbers.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "metrics": [ "bp", ], @@ -1672,6 +1985,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -1679,15 +1993,28 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "I ", "type": "token", @@ -1866,6 +2193,16 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "metrics": [ "bp", ], @@ -1915,6 +2252,9 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -1922,7 +2262,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -1946,10 +2286,10 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -1960,12 +2300,33 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "appendMessage", "content": "Your systolic averaged 128 mmHg.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2010,6 +2371,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -2017,15 +2379,28 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -2048,6 +2423,16 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2120,6 +2505,9 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -2127,7 +2515,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -2151,10 +2539,10 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -2165,7 +2553,18 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -2181,6 +2580,16 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` "call": "appendMessage", "content": "Your systolic averaged 128 mmHg.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2219,6 +2628,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -2226,15 +2636,28 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -2257,6 +2680,16 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2323,6 +2756,9 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -2330,7 +2766,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -2354,10 +2790,10 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -2368,7 +2804,18 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -2384,6 +2831,16 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar "call": "appendMessage", "content": "Your systolic averaged 128 mmHg.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2414,6 +2871,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -2421,15 +2879,28 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -2452,6 +2923,16 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2510,6 +2991,9 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -2517,7 +3001,7 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -2541,10 +3025,10 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -2555,7 +3039,18 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -2599,6 +3094,9 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -2606,7 +3104,7 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = ` ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -2630,10 +3128,10 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -2644,7 +3142,18 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, ], "contentType": "text/event-stream; charset=utf-8", @@ -2679,6 +3188,9 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -2686,7 +3198,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` ], }, { - "amount": 1800, + "amount": 12896, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -2710,10 +3222,10 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -2724,7 +3236,18 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11059, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1837, }, { "call": "prisma.user.update", @@ -2756,6 +3279,16 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` "call": "appendMessage", "content": "A short home-measurement week would help.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2791,6 +3324,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -2798,15 +3332,28 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "A ", "type": "token", @@ -2833,6 +3380,16 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2904,6 +3461,9 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -2911,7 +3471,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` ], }, { - "amount": 1800, + "amount": 12896, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -2935,10 +3495,10 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -2949,7 +3509,18 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11059, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1837, }, { "call": "annotate", @@ -2963,6 +3534,16 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` "call": "appendMessage", "content": "A short home-measurement week would help.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -2993,6 +3574,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -3000,15 +3582,28 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "A ", "type": "token", @@ -3035,6 +3630,16 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3093,6 +3698,9 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -3100,7 +3708,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` ], }, { - "amount": 1800, + "amount": 12892, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -3124,10 +3732,10 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -3138,7 +3746,18 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11056, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "annotate", @@ -3151,6 +3770,16 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` "call": "appendMessage", "content": "An annual blood panel keeps this on track.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3191,6 +3820,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -3198,15 +3828,28 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "An ", "type": "token", @@ -3241,6 +3884,16 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3322,6 +3975,9 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -3329,7 +3985,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` ], }, { - "amount": 1800, + "amount": 12896, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -3353,10 +4009,10 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -3367,7 +4023,18 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11059, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1837, }, { "call": "captureReminderFromSentinel", @@ -3385,6 +4052,16 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` "call": "appendMessage", "content": "Noted, confirm it in settings and I'll bring it back.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3415,6 +4092,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -3422,15 +4100,28 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Noted, ", "type": "token", @@ -3473,6 +4164,16 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3531,6 +4232,9 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -3538,7 +4242,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` ], }, { - "amount": 1800, + "amount": 12896, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -3562,10 +4266,10 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -3576,7 +4280,18 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11059, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1837, }, { "call": "annotate", @@ -3589,6 +4304,16 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` "call": "appendMessage", "content": "Noted, confirm it in settings and I'll bring it back.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3619,6 +4344,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -3626,15 +4352,28 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Noted, ", "type": "token", @@ -3677,6 +4416,16 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3735,6 +4484,9 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -3742,7 +4494,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a ], }, { - "amount": 1800, + "amount": 12895, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -3766,10 +4518,10 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -3780,7 +4532,18 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11058, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1837, }, { "call": "annotate", @@ -3795,6 +4558,16 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a "call": "appendMessage", "content": "Resting heart rate is a good trend signal, /learn/resting-heart-rate.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3825,6 +4598,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -3832,15 +4606,28 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Resting ", "type": "token", @@ -3879,6 +4666,16 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -3937,6 +4734,9 @@ exports[`coach chat golden transcripts > 16a coach.provider.none via an empty ch { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "annotate", "metaKeys": [], @@ -3976,6 +4776,9 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -4027,6 +4830,36 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy "call": "appendMessage", "content": "Your systolic averaged 128 mmHg over the window.", "metricSource": { + "activity": [ + { + "count": 1, + "durationMs": 0, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "done", + "stepRef": "s1", + }, + { + "durationMs": 0, + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "done", + }, + { + "id": "a3", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -4061,6 +4894,7 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -4068,9 +4902,11 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, @@ -4089,6 +4925,67 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy }, "type": "step", }, + { + "activity": { + "count": 1, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "running", + "stepRef": "s1", + }, + "type": "activity", + }, + { + "activity": { + "count": 1, + "durationMs": 0, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "done", + "stepRef": "s1", + }, + "type": "activity", + }, + { + "activity": { + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "running", + }, + "type": "activity", + }, + { + "activity": { + "durationMs": 0, + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "done", + }, + "type": "activity", + }, + { + "activity": { + "id": "a3", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -4123,6 +5020,36 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy }, { "metricSource": { + "activity": [ + { + "count": 1, + "durationMs": 0, + "id": "a1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "phase": "fetch", + "round": 1, + "status": "done", + "stepRef": "s1", + }, + { + "durationMs": 0, + "id": "a2", + "label": "Thinking…", + "labelKey": "insights.coach.activity.thinking", + "phase": "thinking", + "round": 1, + "status": "done", + }, + { + "id": "a3", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -4209,6 +5136,9 @@ exports[`coach chat golden transcripts > 16d coach.provider.none from the capabi { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -4254,6 +5184,9 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "isModuleEnabled", "module": "workouts", @@ -4274,7 +5207,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` ], }, { - "amount": 1800, + "amount": 12890, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -4298,10 +5231,10 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -4312,12 +5245,33 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11054, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "appendMessage", "content": "That run held an average heart rate of 148 bpm.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 2400, 148, @@ -4350,6 +5304,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -4357,15 +5312,28 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "That ", "type": "token", @@ -4408,6 +5376,16 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 2400, 148, @@ -4468,6 +5446,9 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "annotate", "metaKeys": [ @@ -4484,7 +5465,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th ], }, { - "amount": 1800, + "amount": 12890, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -4508,10 +5489,10 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -4522,12 +5503,33 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11054, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "appendMessage", "content": "I can't look at that workout right now.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -4558,6 +5560,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -4565,15 +5568,28 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "I ", "type": "token", @@ -4608,6 +5624,16 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -4666,6 +5692,9 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -4673,7 +5702,7 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -4697,10 +5726,10 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -4711,7 +5740,18 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "event.setError", @@ -4729,6 +5769,17 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "code": "coach.provider.unavailable", "message": "coach.provider.unavailable", @@ -4766,7 +5817,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior ], }, { - "amount": 1800, + "amount": 13053, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -4790,10 +5841,10 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -4804,7 +5855,18 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11202, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1851, }, { "call": "annotate", @@ -4820,6 +5882,16 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior "call": "appendMessage", "content": "Your weight was 91 kg and […] kg at the start.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -4851,6 +5923,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -4858,15 +5931,28 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -4913,6 +5999,16 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -4972,6 +6068,9 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -4979,7 +6078,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope ], }, { - "amount": 1800, + "amount": 12892, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -5003,10 +6102,10 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 30000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -5017,12 +6116,33 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11056, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "appendMessage", "content": "Thanks, that helps.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -5053,6 +6173,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -5060,15 +6181,28 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Thanks, ", "type": "token", @@ -5083,6 +6217,16 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -5141,6 +6285,9 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -5148,7 +6295,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self ], }, { - "amount": 1800, + "amount": 12917, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -5172,10 +6319,10 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self "window": "last30days", }, "signal": true, - "system": "c61eecc94d49:3280", + "system": "7c188ee61f98:5016", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -5186,12 +6333,33 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11078, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1839, }, { "call": "appendMessage", "content": "Du läufst 30 km pro Woche, dein Blutdruck liegt bei 128 mmHg.", "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Schreibt die Antwort…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -5222,6 +6390,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -5229,15 +6398,28 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "activity": { + "id": "a1", + "label": "Schreibt die Antwort…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Du ", "type": "token", @@ -5288,6 +6470,16 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self }, { "metricSource": { + "activity": [ + { + "id": "a1", + "label": "Schreibt die Antwort…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -5346,6 +6538,9 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, { "call": "assertConsentForChain", "chain": [ @@ -5353,7 +6548,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em ], }, { - "amount": 1800, + "amount": 12891, "call": "reserveBudget", "cap": 2000000, "dateKey": "2026-09-26", @@ -5377,10 +6572,10 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "37f58b1a7608:4923", "temperature": 0.4, "timeoutMs": 180000, - "tools": 2, + "tools": 6, }, { "actual": 80, @@ -5391,12 +6586,64 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em "cached": 0, "call": "reconcileSpend", "dateKey": "2026-09-26", - "reserved": 1800, + "reserved": 11055, + }, + { + "actual": 0, + "attribution": { + "reservedOwner": "user", + "servedBy": null, + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1836, }, { "call": "appendMessage", "content": "Your systolic averaged 128 mmHg.", "metricSource": { + "activity": [ + { + "count": 142, + "durationMs": 0, + "id": "a1", + "label": "Fetching Blood pressure, last 90 days…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": NaN, + "status": "done", + "stepRef": "s1", + }, + { + "durationMs": 0, + "id": "a2", + "label": "Fetching Sleep, all time…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": NaN, + "status": "empty", + "stepRef": "s2", + }, + { + "durationMs": 0, + "id": "a3", + "label": "Fetching Lab results, last 12 months…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": NaN, + "status": "failed", + "stepRef": "s3", + }, + { + "id": "a4", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -5474,6 +6721,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em { "call": "annotate", "metaKeys": [ + "activityEntries", "clusterCount", "conversationId", "historyTurns", @@ -5481,9 +6729,11 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em "promptVersion", "provider", "snapshotSent", + "stop", "tokens", "toolMode", "toolsCalled", + "trailStored", ], "name": "insights.coach.replied", }, @@ -5502,6 +6752,18 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, "type": "step", }, + { + "activity": { + "id": "a1", + "label": "Fetching Blood pressure, last 90 days…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "running", + "stepRef": "s1", + }, + "type": "activity", + }, { "step": { "domain": "sleep", @@ -5514,6 +6776,18 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, "type": "step", }, + { + "activity": { + "id": "a2", + "label": "Fetching Sleep, all time…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "running", + "stepRef": "s2", + }, + "type": "activity", + }, { "step": { "domain": "labs", @@ -5526,6 +6800,18 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, "type": "step", }, + { + "activity": { + "id": "a3", + "label": "Fetching Lab results, last 12 months…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "running", + "stepRef": "s3", + }, + "type": "activity", + }, { "step": { "count": 142, @@ -5539,6 +6825,20 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, "type": "step", }, + { + "activity": { + "count": 142, + "durationMs": 0, + "id": "a1", + "label": "Fetching Blood pressure, last 90 days…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "done", + "stepRef": "s1", + }, + "type": "activity", + }, { "step": { "domain": "sleep", @@ -5552,6 +6852,19 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, "type": "step", }, + { + "activity": { + "durationMs": 0, + "id": "a2", + "label": "Fetching Sleep, all time…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "empty", + "stepRef": "s2", + }, + "type": "activity", + }, { "step": { "domain": "labs", @@ -5565,6 +6878,30 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, "type": "step", }, + { + "activity": { + "durationMs": 0, + "id": "a3", + "label": "Fetching Lab results, last 12 months…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "failed", + "stepRef": "s3", + }, + "type": "activity", + }, + { + "activity": { + "id": "a4", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "running", + }, + "type": "activity", + }, { "token": "Your ", "type": "token", @@ -5587,6 +6924,47 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em }, { "metricSource": { + "activity": [ + { + "count": 142, + "durationMs": 0, + "id": "a1", + "label": "Fetching Blood pressure, last 90 days…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "done", + "stepRef": "s1", + }, + { + "durationMs": 0, + "id": "a2", + "label": "Fetching Sleep, all time…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "empty", + "stepRef": "s2", + }, + { + "durationMs": 0, + "id": "a3", + "label": "Fetching Lab results, last 12 months…", + "labelKey": "insights.coach.activity.fetching", + "phase": "fetch", + "round": null, + "status": "failed", + "stepRef": "s3", + }, + { + "id": "a4", + "label": "Writing the answer…", + "labelKey": "insights.coach.activity.answer", + "phase": "answer", + "round": 1, + "status": "done", + }, + ], "groundedFigures": [ 128, 82, @@ -5779,6 +7157,9 @@ exports[`coach chat golden transcripts — refusals before the stream > R6 conse { "call": "storeDeterministicFacts", }, + { + "call": "enqueueCoachMemoryRefresh", + }, ], "contentType": null, "frames": [], diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts index 25c9118ca..14f787db9 100644 --- a/src/app/api/insights/chat/__tests__/dialog-harness.ts +++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts @@ -458,7 +458,11 @@ export const modules = { coachConversation: { findFirst: async () => ({ id: CONVERSATION_ID }), }, - coachMessage: { findMany: async () => latestRows() }, + coachMessage: { + findMany: async () => latestRows(), + // v1.41 — the clarification brake's count of today's questions. + count: async () => 0, + }, measurement: { groupBy: async (args: { where: { type: { in: string[] } } }) => measurementBounds(args), @@ -473,7 +477,10 @@ export const modules = { labResult: { aggregate: async () => EMPTY_AGGREGATE }, }, }), - rateLimit: () => ({ checkRateLimit: m.checkRateLimit }), + rateLimit: () => ({ + checkRateLimit: m.checkRateLimit, + refundRateLimit: async () => {}, + }), serverLocale: () => ({ resolveServerLocale: async (args: { override?: string }) => args.override ?? "en", diff --git a/src/app/api/insights/chat/__tests__/golden-harness.ts b/src/app/api/insights/chat/__tests__/golden-harness.ts index 24eb8df5c..867f12e45 100644 --- a/src/app/api/insights/chat/__tests__/golden-harness.ts +++ b/src/app/api/insights/chat/__tests__/golden-harness.ts @@ -175,6 +175,8 @@ export function resetGolden(): void { }, ); m.getEvent.mockReturnValue({ + // A request event, so the per-request cache memoises as in production. + getKind: () => "request", setError: (err: Error) => record("event.setError", { message: err.message }), }); @@ -373,7 +375,26 @@ export async function runCoachToolLoop(args: Record) { timeoutMs: args.timeoutMs, signal: args.signal instanceof AbortSignal, }); - return m.runCoachToolLoop(args); + const out = await m.runCoachToolLoop(args); + // The real loop settles each round on the turn's ledger as it returns; + // the scripted loop settles its whole cost as one round. + const spend = args.spend as + | { + settleRound(usage: { + tokens: number; + cachedTokens: number; + servedBy: string; + final: boolean; + }): Promise; + } + | undefined; + await spend?.settleRound({ + tokens: out.totalTokens, + cachedTokens: out.cachedTokens, + servedBy: out.workingProviderType, + final: false, + }); + return out; } export async function runStreamingRawCompletionWithFallback( diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index a0114a14d..1703970b6 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -63,9 +63,14 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate }, coachConversation: { findFirst: h.m.conversationFindFirst }, coachMessage: { findMany: h.m.coachMessageFindMany }, + // v1.41 — the operator's reasoning controls; an untouched instance. + appSettings: { findUnique: vi.fn(async () => null) }, }, })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: h.m.checkRateLimit, + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: h.m.resolveServerLocale, })); diff --git a/src/app/api/insights/chat/__tests__/route-activity.test.ts b/src/app/api/insights/chat/__tests__/route-activity.test.ts new file mode 100644 index 000000000..92cb32fe5 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-activity.test.ts @@ -0,0 +1,345 @@ +/** + * v1.41 — the live trail and the dialog of a turn through the real route, + * pipeline and loop, with a scripted provider: + * + * - frame order: the trail and the steps first (thinking before the + * provider is called, each fetch, the digest), interim tables as soon as + * they are read, the answer entry before the first token, then the reply + * frames as before; + * - the stored trail carries the model text, the provenance only the + * structure; + * - a question asked through the tool ends the turn with its choices; + * - a person may run two turns at once, not three; + * - a tapped decision is answered without a model, and a stale one goes to + * the model as an ordinary message. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = await vi.hoisted( + () => import("@/app/api/insights/chat/__tests__/dialog-harness"), +); +const contract = vi.hoisted(() => ({ + decideFactProposal: vi.fn(), + decidePlanProposal: vi.fn(), +})); + +vi.mock("@/lib/api-handler", () => h.modules.apiHandler()); +vi.mock("@/lib/api-response", () => h.modules.apiResponse()); +vi.mock("@/lib/modules/gate", () => h.modules.gate()); +vi.mock("@/lib/ai/capabilities/gate", () => h.modules.capabilities()); +vi.mock("@/lib/logging/context", () => h.modules.logging()); +vi.mock("@/lib/auth/audit", () => h.modules.audit()); +vi.mock("@/lib/db", () => h.modules.db()); +vi.mock("@/lib/rate-limit", () => h.modules.rateLimit()); +vi.mock("@/lib/i18n/server-locale", () => h.modules.serverLocale()); +vi.mock("@/lib/ai/provider-runner", () => h.modules.providerRunner()); +vi.mock("@/lib/ai/provider", () => h.modules.provider()); +vi.mock("@/lib/ai/consent-guard", () => h.modules.consent()); +vi.mock("@/lib/ai/coach/persistence", () => h.modules.persistence()); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => h.modules.memory()); +vi.mock("@/lib/ai/coach/facts", () => h.modules.facts()); +vi.mock("@/lib/ai/coach/budget", () => h.modules.budget()); +vi.mock("@/lib/ai/coach/about-me", () => h.modules.aboutMe()); +vi.mock("@/lib/ai/coach/snapshot", () => h.modules.snapshot()); +vi.mock("@/lib/medications/scheduled-doses", () => h.modules.scheduledDoses()); +vi.mock("@/lib/ai/coach/workout-evidence-builder", () => + h.modules.workoutEvidence(), +); +vi.mock("@/lib/ai/coach/suggest-gate", () => h.modules.suggestGate()); +vi.mock("@/lib/monitoring-settings", () => h.modules.glitchtipSettings()); +vi.mock("@/lib/monitoring/glitchtip", () => h.modules.glitchtip()); +vi.mock("@/lib/tz/resolver", () => h.modules.timezone()); +vi.mock("@/lib/measurements/daily-series-read", () => h.modules.dailySeries()); +vi.mock("@/lib/rollups/measurement-read", () => h.modules.sourcePriority()); +vi.mock("@/lib/ai/coach/bytes-codec", () => h.modules.bytesCodec()); +vi.mock("@/lib/ai/coach/tools/inventory", async (importOriginal) => ({ + ...(await importOriginal()), + buildCoachDataInventory: h.modules.buildInventory, +})); +vi.mock("@/lib/ai/coach/memory/contract", async (importOriginal) => ({ + ...(await importOriginal()), + decideFactProposal: contract.decideFactProposal, + decidePlanProposal: contract.decidePlanProposal, +})); + +import type { CoachActivity, CoachProvenance } from "@/lib/ai/coach/types"; +import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory"; +import { POST } from "@/app/api/insights/chat/route"; + +const post = POST as unknown as (req: Request) => Promise; +const { world, providerCalls, framesOf, m } = h; + +const series = (metric: string, count: number): InventoryEntry => ({ + tool: "get_metric_series", + metric, + domain: metric, + present: true, + count, +}); + +beforeEach(() => { + vi.setSystemTime(h.NOW); + h.resetDialog(); + contract.decideFactProposal.mockReset(); + contract.decidePlanProposal.mockReset(); + world.inventory = [ + series("bp", 58), + series("pulse", 61), + series("resting_hr", 240), + series("walking_hr", 212), + ]; +}); + +function rank(frame: { type: string; interim?: boolean }): number { + if (frame.type === "activity" || frame.type === "step") return 0; + if (frame.type === "result" && frame.interim) return 0; + return [ + "live", + "token", + "provenance", + "result", + "suggestion", + "suggestedAction", + "memoryNote", + "planProposal", + "clarification", + "followUps", + "done", + ].indexOf(frame.type); +} + +describe("the live trail", () => { + it("streams every phase before the first token, in the promised order", async () => { + world.script = [ + { + text: "Let me look at your blood pressure first.", + calls: [ + { + name: "get_metric_table", + args: { metric: "bp", window: "last30days" }, + }, + ], + }, + { text: "Your blood pressure held steady. result:r1" }, + ]; + const { status, frames } = await h.postTurn(post, { + message: "How was my blood pressure?", + locale: "en", + }); + expect(status).toBe(200); + expect(frames.at(-1)?.type).toBe("done"); + const order = frames.map(rank); + expect(order.every((r) => r >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + + const activity = framesOf<{ activity: CoachActivity }>( + frames, + "activity", + ).map((f) => f.activity); + // The thinking entry opens first; the answer entry is the last live one. + expect(activity[0]).toMatchObject({ phase: "thinking", status: "running" }); + expect(activity.at(-1)).toMatchObject({ + phase: "answer", + status: "running", + }); + const phases = new Set(activity.map((a) => a.phase)); + for (const phase of [ + "thinking", + "checkpoint", + "fetch", + "digest", + "answer", + ]) { + expect(phases).toContain(phase); + } + const fetch = activity.filter((a) => a.phase === "fetch").at(-1)!; + expect(fetch).toMatchObject({ + status: "done", + stepRef: "s1", + labelKey: "insights.coach.activity.fetching", + }); + expect(fetch.label).toBe("Fetching Blood pressure, last 30 days…"); + const checkpoint = activity.filter((a) => a.phase === "checkpoint").at(-1)!; + expect(checkpoint.title).toBe("Let me look at your blood pressure first."); + + // The table went out as soon as it was read, before the first token. + const firstToken = frames.findIndex((f) => f.type === "token"); + const interim = frames.findIndex( + (f) => f.type === "result" && f.interim === true, + ); + expect(interim).toBeGreaterThan(-1); + expect(interim).toBeLessThan(firstToken); + }); + + it("persists the structure on the provenance and the text in the trail", async () => { + world.script = [ + { + text: "Let me look at your blood pressure first.", + calls: [ + { + name: "get_metric_table", + args: { metric: "bp", window: "last30days" }, + }, + ], + }, + { text: "Your blood pressure held steady. result:r1" }, + ]; + await h.postTurn(post, { + message: "How was my blood pressure?", + locale: "en", + }); + const assistant = m.appendMessage.mock.calls + .map( + (c) => + c[0] as { + role: string; + metricSource?: CoachProvenance; + trail?: unknown; + }, + ) + .find((args) => args.role === "assistant")!; + const provenance = assistant.metricSource!; + expect(provenance.activity?.length).toBeGreaterThan(3); + // The answer entry is stored closed. + expect(provenance.activity?.at(-1)).toMatchObject({ + phase: "answer", + status: "done", + }); + expect(JSON.stringify(provenance)).not.toContain( + "look at your blood pressure", + ); + expect(assistant.trail).toMatchObject({ + entries: [ + expect.objectContaining({ + title: "Let me look at your blood pressure first.", + }), + ], + }); + }); +}); + +describe("a question asked through the tool", () => { + it("ends the turn with the question and its choices, no chips", async () => { + world.script = [ + { + calls: [ + { + name: "ask_clarification", + args: { + kind: "metric", + question: + "Do you mean resting or walking heart rate? Otherwise I'll look at resting.", + choices: ["resting_hr", "walking_hr"], + assumption: "resting_hr", + }, + }, + ], + }, + ]; + const { frames } = await h.postTurn(post, { + message: "Is my heart rate good?", + locale: "en", + }); + expect(providerCalls).toHaveLength(1); + const prose = framesOf<{ token: string }>(frames, "token") + .map((f) => f.token) + .join(""); + expect(prose).toBe( + "Do you mean resting or walking heart rate? Otherwise I'll look at resting.", + ); + const [clarification] = framesOf<{ + clarification: { kind: string; assumption?: string; choices: unknown[] }; + }>(frames, "clarification"); + expect(clarification.clarification).toMatchObject({ + kind: "metric", + assumption: "c1", + }); + expect(framesOf(frames, "followUps")).toHaveLength(0); + const asking = framesOf<{ activity: CoachActivity }>( + frames, + "activity", + ).find((f) => f.activity.phase === "asking"); + expect(asking?.activity.label).toBe("Asking you…"); + }); +}); + +describe("concurrent turns", () => { + it("refuses a third turn at once and gives its slot back", async () => { + m.checkRateLimit.mockImplementation(async (key: string) => + key.startsWith("coach-turn-active:") + ? { allowed: false, resetAt: 0 } + : { allowed: true, resetAt: 0 }, + ); + const { status } = await h.postTurn(post, { + message: "How was my blood pressure?", + locale: "en", + }); + expect(status).toBe(429); + expect(providerCalls).toHaveLength(0); + expect(m.checkRateLimit).toHaveBeenCalledWith( + "coach-turn-active:u1", + 2, + 180_000, + ); + }); +}); + +describe("a tapped decision", () => { + beforeEach(() => { + world.priorTurns = [ + { role: "user", content: "I started a weekly injection in May." }, + { + role: "assistant", + content: "Noted. Shall I remember that you take a weekly injection?", + }, + ]; + world.storedProvenance = { + memoryNote: { proposal: true, proposalId: "p1", category: "medication" }, + }; + }); + + it("saves through the contract and answers with the catalog line, no model", async () => { + contract.decideFactProposal.mockResolvedValue({ + kind: "saved", + factId: "f1", + }); + const { frames } = await h.postTurn(post, { + message: "Yes, remember it", + locale: "en", + conversationId: h.CONVERSATION_ID, + memoryDecision: { + messageId: h.LAST_ASSISTANT_ID, + proposalId: "p1", + accept: true, + }, + }); + expect(providerCalls).toHaveLength(0); + expect(contract.decideFactProposal).toHaveBeenCalledWith({ + userId: "u1", + conversationId: h.CONVERSATION_ID, + messageId: h.LAST_ASSISTANT_ID, + proposalId: "p1", + accept: true, + }); + const prose = framesOf<{ token: string }>(frames, "token") + .map((f) => f.token) + .join(""); + expect(prose).toBe("Got it, I'll remember."); + expect(m.appendMessage).toHaveBeenCalledWith( + expect.objectContaining({ role: "assistant", providerType: "decision" }), + ); + }); + + it("goes to the model with the tap's label when the proposal is not on the latest reply", async () => { + world.script = [{ text: "Sure." }]; + const { frames } = await h.postTurn(post, { + message: "Yes, remember it", + locale: "en", + conversationId: h.CONVERSATION_ID, + memoryDecision: { messageId: "m-older", proposalId: "p1", accept: true }, + }); + expect(contract.decideFactProposal).not.toHaveBeenCalled(); + expect(providerCalls).toHaveLength(1); + expect(frames.at(-1)?.type).toBe("done"); + }); +}); diff --git a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts index 1ad9881f1..e76d8a5e2 100644 --- a/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts +++ b/src/app/api/insights/chat/__tests__/route-cancelled-turn.test.ts @@ -54,6 +54,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), + refundRateLimit: vi.fn(async () => {}), })); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), diff --git a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts index 9bcfdfa39..ea52a5815 100644 --- a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts @@ -52,7 +52,10 @@ vi.mock("@/lib/api-response", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: {} })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); diff --git a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts index 6c0f19532..203fde324 100644 --- a/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-fence-guard.test.ts @@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), + refundRateLimit: vi.fn(async () => {}), })); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), diff --git a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts index 740f2be72..d63bc8a7f 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-activation.test.ts @@ -69,7 +69,10 @@ vi.mock("@/lib/db", () => ({ const { checkRateLimit } = vi.hoisted(() => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit, + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); diff --git a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts index f47849ff3..4f455a742 100644 --- a/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard-ledger.test.ts @@ -62,7 +62,10 @@ vi.mock("@/lib/db", () => ({ const { checkRateLimit } = vi.hoisted(() => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit, + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index 762518cf6..b47f19163 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -53,7 +53,10 @@ vi.mock("@/lib/db", () => ({ const { checkRateLimit } = vi.hoisted(() => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit, + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts index f22d84152..59beef5cf 100644 --- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -38,7 +38,10 @@ vi.mock("@/lib/api-response", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: {} })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index 524273dc1..afe28acfc 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -51,6 +51,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), + refundRateLimit: vi.fn(async () => {}), })); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 3437fce2b..19749f312 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -70,7 +70,10 @@ vi.mock("@/lib/db", () => ({ const { checkRateLimit } = vi.hoisted(() => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit, + refundRateLimit: vi.fn(async () => {}), +})); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), })); @@ -316,21 +319,25 @@ describe("coach chat — tool-mode routing (F1)", () => { expect(args.messages[0].content).toContain("2400"); }); - it("reserves the multi-round budget in tool mode", async () => { + it("reserves round one and the final answer up front in tool mode", async () => { resolveProviderChain.mockResolvedValue([ { providerType: "anthropic", instance: {} }, ]); await post(chatReq({ message: "How is my BP?" })); - // maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware - // daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain. + // v1.41 — the first round (its input estimate plus a round's answer and + // the default medium thinking budget) and the final answer's reserve, in + // one reservation. The 4th arg is the provider-aware daily cap — + // the user-plan ceiling for this BYOK chain. expect(reserveBudget).toHaveBeenCalledWith( "u1", - 4500, + expect.any(Number), "2026-06-21", 2_000_000, "user", "coach", ); + const reserved = (reserveBudget.mock.calls[0] as unknown[])[1] as number; + expect(reserved).toBeGreaterThanOrEqual(600 + 4_096 + 1_200); }); it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => { diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts index 6ee64e7e1..10081bc48 100644 --- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts +++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts @@ -132,6 +132,7 @@ vi.mock("@/lib/workouts/sport-context", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(async () => ({ allowed: true })), + refundRateLimit: vi.fn(async () => {}), })); vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn(async () => "en"), diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index e89e981c4..971f391f8 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -29,7 +29,7 @@ import { sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { checkRateLimit } from "@/lib/rate-limit"; +import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -37,6 +37,8 @@ import { coachChatRequestSchema } from "@/lib/ai/coach/types"; import { listConversations } from "@/lib/ai/coach/persistence"; import { detectRefusal } from "@/lib/ai/coach/refusal"; import { runCoachTurn } from "@/lib/ai/coach/turn/pipeline"; +import type { ReasoningLevel } from "@/lib/ai/reasoning/levels"; +import { resolveCoachTurnReasoningLevel } from "@/lib/ai/reasoning/controls"; import { streamProviderError, streamRefusal } from "@/lib/ai/coach/turn/sse"; /** @@ -63,6 +65,19 @@ async function coachCapabilityRefusal(): Promise { } } +/** + * v1.41 — at most this many Coach turns of one person at once. A budgeted + * turn can run for minutes; three tabs firing at the same time would run + * three of them against one daily budget and one provider account. + */ +const COACH_CONCURRENT_TURNS = 2; +/** + * How long a turn's slot is held at most: a turn's wall time (150 s) plus + * the streaming of its reply. A slot whose release never arrived (a crashed + * process) frees itself when the window ends. + */ +const COACH_TURN_SLOT_MS = 180_000; + async function handleChatRequest(request: NextRequest): Promise { const auth = await requireAuth(); // The `coach` capability, right after auth and before the rate limit and @@ -111,6 +126,8 @@ async function handleChatRequest(request: NextRequest): Promise { workoutId, followUp, clarification, + memoryDecision, + planDecision, } = parsed.data; // Per-user request-rate ceiling layered in front of the daily budget @@ -162,6 +179,38 @@ async function handleChatRequest(request: NextRequest): Promise { }); } + // v1.41 — the person's effective reasoning level: their choice after the + // operator's switch and highest level, from the shared resolver. Who pays + // is not known until the turn resolves its chain, so the turn applies the + // operator-paid ceiling itself (`turn/reasoning.ts`); the provider's own + // ability is left to its client. + const reasoningLevel: ReasoningLevel = await resolveCoachTurnReasoningLevel( + auth.user.coachPrefsJson, + ); + + // v1.41 — the person's turn slot. The counter is the rate-limit row's own + // (an atomic upsert), given back when the turn is over; a refused slot is + // given back at once, since the refused attempt never ran. + const slotKey = `coach-turn-active:${userId}`; + const slot = await checkRateLimit( + slotKey, + COACH_CONCURRENT_TURNS, + COACH_TURN_SLOT_MS, + ); + const releaseSlot = () => { + void refundRateLimit(slotKey).catch(() => { + // The window frees the slot on its own. + }); + }; + if (!slot.allowed) { + releaseSlot(); + annotate({ + action: { name: "insights.coach.concurrent-limited" }, + meta: { limit: COACH_CONCURRENT_TURNS }, + }); + return apiError("Too many Coach requests, please wait a moment", 429); + } + // Conversation, context, chain, egress re-check, budget, and the streamed // reply: `src/lib/ai/coach/turn/`. return runCoachTurn({ @@ -175,6 +224,10 @@ async function handleChatRequest(request: NextRequest): Promise { workoutId, followUp, clarification, + memoryDecision, + planDecision, + reasoningLevel, + releaseSlot, recheckCapability: coachCapabilityRefusal, }); } diff --git a/src/app/api/insights/coach-read/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts index 0faf29c0d..4425cd2eb 100644 --- a/src/app/api/insights/coach-read/__tests__/route.test.ts +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -13,9 +13,17 @@ import { NextRequest } from "next/server"; */ const buildCoachReadStrip = vi.fn(); +const zoneSeen = vi.fn(); vi.mock("@/lib/insights/derived/coach-read", () => ({ - buildCoachReadStrip: (userId: string, metric: string, locale: string) => - buildCoachReadStrip(userId, metric, locale), + buildCoachReadStrip: ( + userId: string, + metric: string, + locale: string, + opts: { tz: string }, + ) => { + zoneSeen(opts.tz); + return buildCoachReadStrip(userId, metric, locale); + }, })); vi.mock("@/lib/db", () => ({ @@ -58,7 +66,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -function sessionWithLocale(locale: string | null) { +function sessionWithLocale(locale: string | null, timezone?: string) { return { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, user: { @@ -66,6 +74,7 @@ function sessionWithLocale(locale: string | null) { username: "testuser", role: "USER" as const, locale, + ...(timezone ? { timezone } : {}), }, }; } @@ -126,3 +135,19 @@ describe("GET /api/insights/coach-read — the reader's language", () => { ]); }); }); + +describe("GET /api/insights/coach-read — the reader's day", () => { + it("passes the reader's own zone, which decides whether a day is still in progress", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null, "America/New_York") as unknown as Awaited< + ReturnType + >, + ); + zoneSeen.mockReset(); + + const res = await GET(REQUEST()); + + expect(res.status).toBe(200); + expect(zoneSeen).toHaveBeenCalledWith("America/New_York"); + }); +}); diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index e7917ed51..5b6f27ebb 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -24,6 +24,7 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; import type { MeasurementType } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; @@ -66,7 +67,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // English, one line under the other. const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); - const strip = await buildCoachReadStrip(user.id, metric, locale); + // The reader's zone decides which day is "today": a glucose day still in + // progress is placed against the same hours of the earlier days. + const strip = await buildCoachReadStrip(user.id, metric, locale, { + tz: await resolveStoredTimezone(user.timezone), + }); annotate({ action: { name: "insights.coach-read" }, diff --git a/src/app/api/insights/coach/facts/[id]/route.ts b/src/app/api/insights/coach/facts/[id]/route.ts index debe5963b..b3222a406 100644 --- a/src/app/api/insights/coach/facts/[id]/route.ts +++ b/src/app/api/insights/coach/facts/[id]/route.ts @@ -1,8 +1,20 @@ /** + * PATCH /api/insights/coach/facts/[id] — edit one Coach fact's wording. * DELETE /api/insights/coach/facts/[id] — soft-delete one Coach fact. * * v1.11.1 — the per-fact "forget this one thing" delete for the durable - * Coach facts surface. + * Coach facts surface. It is also the "Undo" under a note the Coach saved in + * a turn (v1.41). + * + * v1.41 — PATCH rewrites the fact's text (re-encrypted) and reads the new + * wording again (`settleCategory`, the lexicon the Coach's own saves pass): + * text that now names a medication moves to `medication`, so the medications + * module still filters it; other health wording moves a non-health category + * to `condition`; otherwise the category stays. A health fact the person + * wrote themselves is confirmed by that edit (`source: "user"`). Only a + * listed fact can be edited: a proposal still waiting for the person's + * answer is not one, and answers 404 like an unknown id. Editing is the + * person's own data, so no Coach or AI gate. * * Ownership + existence privacy: the soft-delete uses `updateMany` scoped * `where: { id, userId, deletedAt: null }` rather than `update`. A @@ -18,14 +30,109 @@ import type { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { isHealthFact } from "@/lib/ai/coach/facts"; +import { settleCategory } from "@/lib/ai/coach/memory/remember"; +import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared"; +import { + COACH_MEMORY_CATEGORIES, + type CoachMemoryCategory, +} from "@/lib/ai/coach/types"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { coachFactPatchSchema } from "@/lib/validations/coach-fact"; interface RouteCtx { params: Promise<{ id: string }>; } +const PATCH_RATE_LIMIT = 40; +const PATCH_WINDOW_MS = 60_000; + +export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `coach-facts:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { id } = await ctx.params; + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 2 * 1024, + }); + if (jsonError) return jsonError; + const parsed = coachFactPatchSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const text = parsed.data.fact.replace(/\s+/g, " "); + const live = { + id, + userId: user.id, + deletedAt: null, + source: { not: PROPOSED_FACT_SOURCE }, + }; + const current = await prisma.coachFact.findFirst({ + where: live, + select: { category: true, source: true }, + }); + if (!current) return apiError("Fact not found", 404); + const known = (COACH_MEMORY_CATEGORIES as readonly string[]).includes( + current.category, + ); + const category = known + ? settleCategory(current.category as CoachMemoryCategory, text) + : current.category; + // A health fact the person wrote is one they confirmed. + const source = isHealthFact(category, text) ? "user" : current.source; + // `updateMany` scoped by id, owner and liveness: an unknown, cross-user, + // deleted or still-proposed id is a 0-count no-op, never a P2025 throw. + const { count } = await prisma.coachFact.updateMany({ + where: live, + data: { factEncrypted: encryptToBytes(text), category, source }, + }); + if (count === 0) { + // Indistinguishable from "owned by someone else" — never reveal which. + return apiError("Fact not found", 404); + } + + const row = await prisma.coachFact.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true, category: true, source: true, updatedAt: true }, + }); + if (!row) return apiError("Fact not found", 404); + + annotate({ + action: { name: "coach.facts.updated" }, + meta: { category: row.category }, + }); + + return apiSuccess({ + fact: { + id: row.id, + category: row.category, + text, + source: row.source, + updatedAt: row.updatedAt.toISOString(), + }, + }); +}); + export const DELETE = apiHandler( async (_request: NextRequest, ctx: RouteCtx) => { const { user } = await requireAuth(); diff --git a/src/app/api/insights/coach/facts/__tests__/route-memory.test.ts b/src/app/api/insights/coach/facts/__tests__/route-memory.test.ts new file mode 100644 index 000000000..314c23803 --- /dev/null +++ b/src/app/api/insights/coach/facts/__tests__/route-memory.test.ts @@ -0,0 +1,300 @@ +/** + * v1.41 — the memory list's write half: the remember button (POST), editing + * a fact (PATCH), and a GET that never lists a proposal still waiting for + * the person's answer. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + coachFact: { + findMany: vi.fn(), + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/ai/coach/memory/remember", async (importOriginal) => ({ + ...(await importOriginal()), + rememberMessageAsFact: vi.fn(), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ + allowed: true, + remaining: 10, + resetAt: new Date(), + })), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +// The capability gate answers "unavailable" throughout: every verb must +// still work, and must not even ask. +vi.mock("@/lib/ai/capabilities/gate", () => ({ + requireAiCapability: vi.fn(async () => { + throw new Error("the coach capability was consulted"); + }), + getAiCapability: vi.fn(async () => ({ + available: false, + reason: "operator_disabled", + onDeviceAllowed: false, + })), +})); + +// Mock the codec so the test never needs an encryption key. The "row" +// carries a tagged Uint8Array and the mock maps it back to a string; +// a sentinel buffer triggers a throw to exercise the fail-closed skip. +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn((buf: Uint8Array) => Buffer.from(buf).toString()), + encryptToBytes: vi.fn((s: string) => new Uint8Array(Buffer.from(s))), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + annotate: vi.fn(), + }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { PATCH } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPost = POST as unknown as (req: NextRequest) => Promise; +const callPatch = PATCH as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +function req(method: string, body: unknown): NextRequest { + return new NextRequest("http://localhost/api/insights/coach/facts", { + method, + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("GET /api/insights/coach/facts (v1.41)", () => { + it("never lists a proposal and returns where each fact came from", async () => { + vi.mocked(prisma.coachFact.findMany).mockResolvedValue([ + { + id: "f1", + category: "goal", + factEncrypted: new Uint8Array(Buffer.from("75 kg by December")), + confidence: 80, + source: "coach", + sourceConversationId: "c1", + sourceMessageId: "m1", + lastUsedAt: new Date("2026-10-05T10:00:00Z"), + createdAt: new Date("2026-10-01T10:00:00Z"), + updatedAt: new Date("2026-10-01T10:00:00Z"), + }, + ] as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { facts: Array> }; + }; + expect(body.data.facts[0]).toMatchObject({ + id: "f1", + text: "75 kg by December", + source: "coach", + sourceMessageId: "m1", + lastUsedAt: "2026-10-05T10:00:00.000Z", + }); + const where = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0] + ?.where as Record; + expect(where.source).toEqual({ not: "proposed" }); + }); +}); + +describe("POST /api/insights/coach/facts", () => { + it("remembers the caller's own message by id", async () => { + vi.mocked(rememberMessageAsFact).mockResolvedValue({ + id: "f9", + category: "medication", + text: "I take ramipril", + source: "user", + created: true, + }); + const res = await callPost(req("POST", { messageId: "um1" })); + expect(res.status).toBe(201); + expect(rememberMessageAsFact).toHaveBeenCalledWith({ + userId: "user-1", + messageId: "um1", + }); + const body = (await res.json()) as { data: Record }; + expect(body.data).toEqual({ + fact: { + id: "f9", + category: "medication", + text: "I take ramipril", + source: "user", + }, + created: true, + }); + }); + + it("never accepts text or a userId in the body", async () => { + const res = await callPost( + req("POST", { messageId: "um1", fact: "anything", userId: "x" }), + ); + expect(res.status).toBe(422); + expect(rememberMessageAsFact).not.toHaveBeenCalled(); + }); + + it("is a 404 for a message the caller does not own", async () => { + vi.mocked(rememberMessageAsFact).mockResolvedValue(null); + const res = await callPost(req("POST", { messageId: "foreign" })); + expect(res.status).toBe(404); + }); + + it("is refused while the Coach module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + const res = await callPost(req("POST", { messageId: "um1" })); + expect(res.status).toBe(403); + expect(rememberMessageAsFact).not.toHaveBeenCalled(); + }); +}); + +describe("PATCH /api/insights/coach/facts/[id]", () => { + const ctx = { params: Promise.resolve({ id: "f1" }) }; + + it("rewrites the text, re-encrypted, scoped to the caller's live facts", async () => { + vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({ + id: "f1", + category: "goal", + source: "coach", + updatedAt: new Date("2026-10-06T10:00:00Z"), + } as never); + const res = await callPatch( + req("PATCH", { fact: " Wants 74 kg by December " }), + ctx, + ); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as { + where: Record; + data: { factEncrypted: Uint8Array }; + }; + expect(arg.where).toEqual({ + id: "f1", + userId: "user-1", + deletedAt: null, + source: { not: "proposed" }, + }); + expect(Buffer.from(arg.data.factEncrypted).toString()).toBe( + "Wants 74 kg by December", + ); + // Not health: the category and the source stay. + expect(arg.data).toMatchObject({ category: "goal", source: "coach" }); + expect(Object.keys(arg.data).sort()).toEqual( + ["category", "factEncrypted", "source"].sort(), + ); + }); + + it.each([ + // Health text under a non-health category: moved, and confirmed. + ["preference", "coach", "Takes metformin with breakfast", "medication"], + ["context", "extracted", "Has asthma since childhood", "condition"], + // A medication named under another health category still moves, so the + // medications module keeps filtering it. + ["condition", "user", "Asthma, uses a salbutamol inhaler", "medication"], + // A health category the new wording no longer reads as: kept. + ["constraint", "coach", "No running before 7", "constraint"], + ])( + "re-reads a %s fact (%s) edited to %j as %s", + async (category, source, fact, expected) => { + vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({ + id: "f1", + category, + source, + updatedAt: new Date("2026-10-06T10:00:00Z"), + } as never); + const res = await callPatch(req("PATCH", { fact }), ctx); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as { + data: { category: string; source: string }; + }; + expect(arg.data.category).toBe(expected); + // A health fact the person wrote themselves is one they confirmed. + expect(arg.data.source).toBe("user"); + }, + ); + + it("is a 404 for an unknown, foreign or proposed id", async () => { + vi.mocked(prisma.coachFact.findFirst).mockResolvedValue(null); + vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await callPatch(req("PATCH", { fact: "Something new" }), ctx); + expect(res.status).toBe(404); + }); + + it("refuses an empty or over-long fact, or extra keys", async () => { + for (const body of [ + { fact: "" }, + { fact: "x".repeat(161) }, + { fact: "fine text", category: "condition" }, + ]) { + const res = await callPatch(req("PATCH", body), ctx); + expect(res.status).toBe(422); + } + expect(prisma.coachFact.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/coach/facts/route.ts b/src/app/api/insights/coach/facts/route.ts index 8a0057149..fa45b596b 100644 --- a/src/app/api/insights/coach/facts/route.ts +++ b/src/app/api/insights/coach/facts/route.ts @@ -1,12 +1,23 @@ /** * GET /api/insights/coach/facts — list the caller's ACTIVE Coach facts. + * POST /api/insights/coach/facts — remember one of the caller's own Coach + * messages (the remember button). * DELETE /api/insights/coach/facts — "forget all": soft-delete every active * fact for the caller. * * v1.11.1 — the GDPR / "forget what you know about me" surface for the - * durable Coach facts the assistant extracts during conversations. Facts - * are server-extracted, not user-authored, so this surface is read + - * delete only (no POST / PATCH). + * durable Coach facts. v1.41 — also the Coach's one memory list: each fact + * says where it came from (`source`), when it was last used in a turn and + * which message it came out of; the person edits a fact through + * `PATCH …/facts/{id}`. A health fact still waiting for the person's answer + * (`source: "proposed"`) is not listed: nothing the person has not agreed to + * appears as something the Coach knows. "Forget all" clears those too. + * + * POST names a message, never text: the server reads the caller's own stored + * message, files it with the medication / condition lexicon (anything else is + * `context`) and saves it as `source: "user"`. The tap is the person's + * confirmation, so a health message is saved too. A message the caller does + * not own, or an assistant message, is a 404. * * Ownership: every query is scoped `where: { userId, ... }`, so a caller * can only ever see or clear their own facts. The fact text is decrypted @@ -20,16 +31,35 @@ * Erasure that depends on the feature being on is no erasure. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { rememberMessageAsFact } from "@/lib/ai/coach/memory/remember"; +import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { coachFactCreateSchema } from "@/lib/validations/coach-fact"; + +// The remember button is one tap per message; the limit only caps a runaway +// client loop, like the plan mutations. +const CREATE_RATE_LIMIT = 40; +const CREATE_WINDOW_MS = 60_000; export const GET = apiHandler(async () => { const { user } = await requireAuth(); const rows = await prisma.coachFact.findMany({ - where: { userId: user.id, deletedAt: null }, + where: { + userId: user.id, + deletedAt: null, + source: { not: PROPOSED_FACT_SOURCE }, + }, // Highest-confidence first, then newest — mirrors the injection // ordering so the management list reads in the same priority the // assistant actually weights the facts. @@ -39,7 +69,12 @@ export const GET = apiHandler(async () => { category: true, factEncrypted: true, confidence: true, + source: true, + sourceConversationId: true, + sourceMessageId: true, + lastUsedAt: true, createdAt: true, + updatedAt: true, }, }); @@ -48,7 +83,12 @@ export const GET = apiHandler(async () => { category: string; text: string; confidence: number; + source: string; + sourceConversationId: string | null; + sourceMessageId: string | null; + lastUsedAt: Date | null; createdAt: Date; + updatedAt: Date; }> = []; for (const row of rows) { @@ -65,7 +105,12 @@ export const GET = apiHandler(async () => { category: row.category, text, confidence: row.confidence, + source: row.source, + sourceConversationId: row.sourceConversationId, + sourceMessageId: row.sourceMessageId, + lastUsedAt: row.lastUsedAt, createdAt: row.createdAt, + updatedAt: row.updatedAt, }); } @@ -77,6 +122,61 @@ export const GET = apiHandler(async () => { return apiSuccess({ facts }); }); +export const POST = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + // Saving to the Coach's memory is Coach use, like confirming a plan; it is + // not model work, so no AI gate. + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `coach-facts:create:${user.id}`, + CREATE_RATE_LIMIT, + CREATE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + const parsed = coachFactCreateSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const remembered = await rememberMessageAsFact({ + userId: user.id, + messageId: parsed.data.messageId, + }); + if (!remembered) { + // Indistinguishable from "owned by someone else" — never reveal which. + return apiError("Message not found", 404); + } + + annotate({ + action: { name: "coach.facts.created" }, + meta: { category: remembered.category, created: remembered.created }, + }); + + return apiSuccess( + { + fact: { + id: remembered.id, + category: remembered.category, + text: remembered.text, + source: remembered.source, + }, + created: remembered.created, + }, + remembered.created ? 201 : 200, + ); +}); + export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index f19736286..3668e2443 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -795,6 +795,9 @@ export const POST = apiHandler((request: NextRequest) => // v1.25 — honours the per-user response-timeout setting. timeoutMs: effectiveTimeoutMs, stage: "generate", + // v1.41 — a briefing asked for by hand reasons exactly like the + // scheduled one; the retries below repair a reply and do not. + reasoningJob: "daily_briefing", }); result = fallback.result; workingProviderType = fallback.workingProvider.providerType; diff --git a/src/app/globals.css b/src/app/globals.css index 5946209ec..1a50e0d52 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -245,7 +245,7 @@ /* Text-field focus border: a neutral, foreground-based tone, no colour * ring. ~5.2:1 on --background, ~6:1 on --card. */ --input-focus: #9a9b9d; - --ring: #bd93f9; + --ring: #c4c9d4; /* neutral: no purple focus frames anywhere */ --chart-1: #bd93f9; --chart-2: #50fa7b; --chart-3: #ff79c6; @@ -375,7 +375,7 @@ --input: oklch(0.87 0.009 300); /* Text-field focus border, neutral: ~4.2:1 on --background. */ --input-focus: oklch(0.56 0.012 295); - --ring: oklch(0.6 0.13 292); + --ring: oklch(0.48 0.012 290); /* neutral: no purple focus frames anywhere */ --chart-1: #644ac9; --chart-2: #14720a; --chart-3: #a3134d; diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 11872bbb9..466be2503 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -14,9 +14,23 @@ import { } from "@/lib/query-keys"; import { SettingsToggle } from "./_shared"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { + DEFAULT_REASONING_MAX_EFFORT, + REASONING_ADMIN_KEYS, + REASONING_LEVEL_LABEL_KEYS, + REASONING_MAX_EFFORTS, + type ReasoningMaxEffort, +} from "@/lib/ai/reasoning/levels"; /** - * Operator-side panel for the five assistant switches. The master stops + * Operator-side panel for the five assistant switches, and (v1.41) the two + * reasoning controls: whether models may think before they answer at all, + * and the highest depth a person may choose. Background jobs follow the + * same switch; there is no separate one. + * + * The five switches: The master stops * every AI feature; four sub-switches each stop one cost or egress profile: * the Coach, the daily briefing, status notes (per-reading notes, workout * notes, reaction lines) and reading documents (the vault, lab scans, @@ -48,8 +62,16 @@ interface AssistantFlagsResponse { insightStatus: boolean; documentAi: boolean; }; + /** v1.41 — the operator's reasoning controls; absent before v1.41. */ + reasoning?: { enabled: boolean; maxEffort: ReasoningMaxEffort }; } +/** What the route accepts: any of the switches, and the reasoning pair. */ +type AssistantFlagsPatch = Partial & { + aiReasoningEnabled?: boolean; + aiReasoningMaxEffort?: ReasoningMaxEffort; +}; + function useAssistantFlags() { return useQuery({ queryKey: queryKeys.adminAssistantFlags(), @@ -66,7 +88,7 @@ function useUpdateAssistantFlags() { const { t } = useTranslations(); return useMutation({ mutationFn: async ( - patch: Partial, + patch: AssistantFlagsPatch, ): Promise => { return apiPut( "/api/admin/settings/assistant-flags", @@ -102,6 +124,7 @@ export function AssistantSection() { const raw = data?.raw; const masterOn = raw?.assistantEnabled ?? true; const disabledSubs = !masterOn || mutation.isPending; + const reasoningOn = data?.reasoning?.enabled ?? true; return ( @@ -159,6 +182,51 @@ export function AssistantSection() { disabled={disabledSubs} /> + + {/* v1.41 — reasoning: allowed at all, and how deep at most. */} +
+ + mutation.mutate({ aiReasoningEnabled: checked }) + } + disabled={disabledSubs} + /> +
+ + + mutation.mutate({ + aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort, + }) + } + > + {REASONING_MAX_EFFORTS.map((effort) => ( + + ))} + +
+
); diff --git a/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx b/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx new file mode 100644 index 000000000..26cdb3fca --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx @@ -0,0 +1,76 @@ +/** + * v1.41 — "Assumed: last 30 days. Change": one quiet line, the change only + * under the latest answer and only with alternatives. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types"; + +import { CoachAssumptionLine, alternativesFor } from "../assumption-line"; + +const WINDOW: CoachAssumption = { + kind: "window", + value: { + labelKey: "coach.step.window.last30days", + label: "last 30 days", + value: { window: "last30days" }, + }, + alternatives: [], +}; +const CHANGE: CoachFollowUp = { + id: "f1", + kind: "change_assumption", + labelKey: "coach.followUp.changeAssumption", + label: "Last 90 days instead", + reuse: false, + origin: "server", + assumption: { kind: "window", value: { window: "last90days" } }, +}; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("names the assumption in one meta line", () => { + const html = render( + , + ); + expect(html).toContain("Assumed: last 30 days."); + expect(html).toMatch( + /data-slot="coach-assumption-line"[^>]*class="text-muted-foreground/, + ); + expect(html).not.toContain("coach-assumption-change"); + }); + + it("offers Change under the latest answer, closed until tapped", () => { + const html = render( + {}} + />, + ); + expect(html).toContain(">Change"); + expect(html).toContain('aria-expanded="false"'); + expect(html).not.toContain("Last 90 days instead"); + }); + + it("matches alternatives to their assumption by kind", () => { + expect(alternativesFor(WINDOW, [CHANGE])).toEqual([CHANGE]); + expect(alternativesFor({ ...WINDOW, kind: "metric" }, [CHANGE])).toEqual( + [], + ); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx b/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx deleted file mode 100644 index e7d28a2f3..000000000 --- a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { getServerTranslator } from "@/lib/i18n/server-translator"; - -// The card's only hook is the translator; the server one resolves the same -// bundles and returns the key for a missing string, like the client one. With -// it, the card can be called as a plain function and its handlers exercised -// (the node test environment has no DOM to click through). -vi.mock("@/lib/i18n/context", () => ({ - useTranslations: () => ({ - t: (key: string) => getServerTranslator("en").t(key), - }), -})); - -const focusComposer = vi.fn(); -vi.mock("../composer-focus", () => ({ - focusCoachComposer: () => focusComposer(), -})); - -import { CoachClarificationCard } from "../clarification-card"; -import type { CoachClarification } from "@/lib/ai/coach/types"; - -const METRIC: CoachClarification = { - kind: "metric", - choices: [ - { - id: "c1", - labelKey: "insights.coach.metric.pulse", - label: "Pulse (server)", - value: { metric: "pulse" }, - }, - { - id: "c2", - labelKey: "coach.not.a.key", - label: "Server label", - value: { metric: "resting_hr" }, - }, - ], - freeText: true, -}; - -function card(clarification: CoachClarification, disabled = false) { - const onChoose = vi.fn(); - const tree = CoachClarificationCard({ - clarification, - messageId: "m1", - disabled, - onChoose, - }); - return { tree, onChoose, html: renderToStaticMarkup(tree) }; -} - -function choiceButtons( - node: ReactNode, -): ReactElement>[] { - if (Array.isArray(node)) return node.flatMap(choiceButtons); - if (!isValidElement(node)) return []; - const el = node as ReactElement>; - const own = el.props["data-choice-id"] ? [el] : []; - return [...own, ...choiceButtons(el.props.children as ReactNode)]; -} - -describe("", () => { - it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => { - const { html } = card(METRIC); - // The card mounts with its content, so a live region on the card itself - // is never heard; the conversation keeps one mounted for it. - expect(html).not.toContain("aria-live"); - expect(html).toContain('aria-label="The Coach has a question"'); - expect(html).not.toContain("autofocus"); - expect(html).not.toContain("autoFocus"); - }); - - it("renders the catalog label, falling back to the server label", () => { - const { html } = card(METRIC); - expect(html).toContain('aria-label="Possible answers"'); - expect(html).toContain(">Pulse"); - expect(html).toContain(">Server label"); - expect(html).toContain("You can also type your own answer."); - }); - - it("sends the tapped choice", () => { - const { tree, onChoose } = card(METRIC); - const buttons = choiceButtons(tree); - expect(buttons).toHaveLength(2); - focusComposer.mockClear(); - (buttons[1].props.onClick as () => void)(); - expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]); - // The choices disable while the answer runs: focus goes to the composer. - expect(focusComposer).toHaveBeenCalledTimes(1); - }); - - it("disables the choices while a turn is in flight", () => { - const { tree } = card(METRIC, true); - for (const button of choiceButtons(tree)) { - expect(button.props.disabled).toBe(true); - } - }); - - it("asks for a typed answer on a context question", () => { - const { html, tree } = card({ - kind: "context", - choices: [], - freeText: true, - }); - expect(choiceButtons(tree)).toHaveLength(0); - expect(html).not.toContain('role="group"'); - expect(html).toContain("Type your answer below."); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index 21626c678..2db2b6126 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -113,16 +113,15 @@ describe(" page deep-link (#67)", () => { expect(html).not.toContain('data-slot="coach-hero"'); }); - it("keeps the clarification live region mounted before any question arrives", () => { - // A region that mounts together with its content is not announced; the - // empty region has to be in the page first. + it("asks no question in a card above the composer", () => { + // v1.41 — a clarifying question is a normal Coach message with reply + // pills under it; there is no card and no separate live region. const html = render( , makeClient(), ); - expect(html).toMatch( - /]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/, - ); + expect(html).not.toContain("coach-clarification-card"); + expect(html).not.toContain("coach-clarification-live"); }); it("auto-opens the most-recent thread when none is pinned", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx index 2d4a8af6a..d24b0fca0 100644 --- a/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-model-picker.test.tsx @@ -8,7 +8,6 @@ import type { import { chainWithFirst, modelControlFor, - reasoningFieldFor, selectableProviders, switchDidNotTake, } from "../coach-model-picker"; @@ -120,20 +119,3 @@ describe("modelControlFor", () => { ).toEqual({ kind: "gateway", value: "qwen3:32b" }); }); }); - -describe("reasoningFieldFor", () => { - it("writes the Local and gateway entries' own reasoning fields", () => { - expect( - reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })), - ).toEqual({ field: "localReasoningEffort", value: "low" }); - expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({ - field: "compatReasoningEffort", - value: null, - }); - }); - - it("has nothing for providers without a reasoning setting", () => { - expect(reasoningFieldFor("openai", userProvider())).toBeNull(); - expect(reasoningFieldFor("codex", userProvider())).toBeNull(); - }); -}); diff --git a/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx new file mode 100644 index 000000000..7b3489779 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-reasoning-field.test.tsx @@ -0,0 +1,166 @@ +/** + * v1.41 — the Coach's thinking depth: Off / Low / Medium / High, rendered + * from what the server resolved, never recomputed. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" })); +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ + t: (key: string, params?: Record) => + getServerTranslator(locale.current).t(key, params), + }), +})); + +import { + CoachReasoningSelect, + reasoningLocked, + reasoningOptions, + reasoningStateOrDefault, + shownReasoningLevel, + type CoachReasoningState, +} from "../coach-reasoning-field"; + +const OPEN: CoachReasoningState = { + level: "medium", + preference: "medium", + maxLevel: "high", + available: true, + offIsReal: true, + source: "user", +}; +const ADMIN_OFF: CoachReasoningState = { + level: "off", + preference: "high", + maxLevel: "off", + available: false, + offIsReal: true, + source: "admin_off", +}; + +function render(state: CoachReasoningState, value = state.level) { + return renderToStaticMarkup( + {}} + />, + ); +} + +describe("the resolved block", () => { + it("reads an older server's missing block as allowed, uncapped, really off", () => { + expect(reasoningStateOrDefault(null)).toEqual(OPEN); + expect(reasoningStateOrDefault(undefined)).toEqual(OPEN); + }); + + it("is locked only when the operator switched reasoning off", () => { + expect(reasoningLocked(ADMIN_OFF)).toBe(true); + expect(reasoningLocked(OPEN)).toBe(false); + // A provider that cannot reason does not lock the person's choice. + expect( + reasoningLocked({ ...OPEN, available: false, source: "unsupported" }), + ).toBe(false); + }); +}); + +describe("", () => { + it("offers Off, Low, Medium and High with the label and hint", () => { + const html = render(OPEN); + expect(html).toContain(">Thinking depth<"); + for (const label of ["Off", "Low", "Medium", "High"]) { + expect(html).toContain(`>${label}`); + } + expect(html).toContain( + "Deeper thinking means more precise answers and a little more waiting.", + ); + expect(html).not.toMatch(/]*\sdisabled=""/); + }); + + it("is locked with one sentence when the admin turned reasoning off", () => { + const html = render(ADMIN_OFF, "high"); + expect(html).toMatch(/]*\sdisabled=""/); + expect(html).toContain('data-locked="true"'); + expect(html).toContain("The admin has turned deeper thinking off."); + expect(html).not.toContain("a little more waiting"); + }); + + it("shows the admin's cap: levels above it are disabled and say why", () => { + const html = render({ ...OPEN, maxLevel: "medium" }); + expect(html).toMatch( + /