diff --git a/CHANGELOG.md b/CHANGELOG.md index 289543a14..775fa162b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ it. It used to be skipped. It is summed per day, charted, listed with your measurements, included in backups and exports, and readable over MCP. It feeds no score and no insight. (#1340) +- When the Coach looks for readings older than the lookback limit you set + for it, its live trail now says so on the wire: the `step` and `activity` + frames carry `reason: "outside_reach"` instead of looking like an empty + window. The native client can then tell "nothing in this window" from + "further back than the Coach may read". The answer still names the limit, + and the web trail keeps its wording. ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0e0af558f..8a90d5d6a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -62336,6 +62336,7 @@ components: enum: - no_data - outside_window + - outside_reach - module_disabled - retrieval_failed - invalid_arguments @@ -62355,7 +62356,10 @@ components: additionalProperties: false description: "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: - never free text, an analyte name or a value." + never free text, an analyte name or a value. `reason` says why a step found nothing or failed: `outside_window` + means no readings in the window read, `outside_reach` that readings exist but all lie past the lookback limit + set for the Coach (the answer names the limit). A client without a string for a reason shows the step without + one." CoachStepDomain: type: string enum: @@ -62916,6 +62920,10 @@ components: - time - cap - no_progress + reason: + type: string + enum: + - outside_reach required: - id - phase @@ -62926,7 +62934,8 @@ components: 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`." + /api/insights/chat/{id}/messages/{messageId}/trail`. `reason` is set only on a settled `fetch` entry whose + readings all lie past the lookback limit set for the Coach (`outside_reach`), mirroring the step it stands for." CoachStop: type: object properties: @@ -63536,6 +63545,10 @@ components: - time - cap - no_progress + reason: + type: string + enum: + - outside_reach title: description: A screened reasoning title or checkpoint sentence. type: string diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts index 178ef3964..5eebdfe1c 100644 --- a/src/app/api/insights/chat/__tests__/dialog-harness.ts +++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts @@ -46,6 +46,8 @@ export interface DialogWorld { resultsWithheld: boolean; /** The provider's rounds, consumed in order. */ script: ScriptRound[]; + /** The stored `User.coachPrefsJson`; null is the defaults. */ + coachPrefs: Record | null; } export const world: DialogWorld = { @@ -56,6 +58,7 @@ export const world: DialogWorld = { storedProvenance: {}, resultsWithheld: false, script: [], + coachPrefs: null, }; /** Everything the scripted provider was sent and answered, in order. */ @@ -412,6 +415,7 @@ export function resetDialog(): void { world.storedProvenance = {}; world.resultsWithheld = false; world.script = []; + world.coachPrefs = null; providerCalls.length = 0; messageSeq = 0; for (const fn of Object.values(m)) fn.mockReset(); @@ -465,7 +469,7 @@ export const modules = { prisma: { user: { findUnique: async () => ({ - coachPrefsJson: null, + coachPrefsJson: world.coachPrefs, displayName: null, aiResponseTimeoutSeconds: null, }), diff --git a/src/app/api/insights/chat/__tests__/route-activity.test.ts b/src/app/api/insights/chat/__tests__/route-activity.test.ts index 04c689803..0b16502a4 100644 --- a/src/app/api/insights/chat/__tests__/route-activity.test.ts +++ b/src/app/api/insights/chat/__tests__/route-activity.test.ts @@ -171,6 +171,62 @@ describe("the live trail", () => { expect(interim).toBeLessThan(firstToken); }); + it("names a read past the lookback limit on its step and its fetch entry", async () => { + world.coachPrefs = { defaultWindow: "last7days" }; + world.script = [ + { calls: [{ name: "get_day", args: { date: "2026-06-01" } }] }, + { text: "That day lies beyond the lookback limit you set." }, + ]; + const { status, frames } = await h.postTurn(post, { + message: "What happened on 1 June?", + locale: "en", + }); + expect(status).toBe(200); + const step = framesOf<{ step: { id: string; status: string } }>( + frames, + "step", + ) + .map((f) => f.step) + .at(-1); + expect(step).toMatchObject({ + tool: "get_day", + status: "empty", + reason: "outside_reach", + }); + const fetch = framesOf<{ activity: CoachActivity }>(frames, "activity") + .map((f) => f.activity) + .filter((a) => a.phase === "fetch") + .at(-1); + expect(fetch).toMatchObject({ + status: "empty", + stepRef: step?.id, + reason: "outside_reach", + }); + }); + + it("names no reason on a fetch inside the limit", async () => { + world.script = [ + { + calls: [ + { + name: "get_metric_table", + args: { metric: "bp", window: "last30days" }, + }, + ], + }, + { text: "Steady. result:r1" }, + ]; + const { frames } = await h.postTurn(post, { + message: "How was my blood pressure?", + locale: "en", + }); + const fetches = framesOf<{ activity: CoachActivity }>(frames, "activity") + .map((f) => f.activity) + .filter((a) => a.phase === "fetch"); + expect(fetches.length).toBeGreaterThan(0); + for (const entry of fetches) expect(entry).not.toHaveProperty("reason"); + }); + it("persists the structure on the provenance and the text in the trail", async () => { world.script = [ { diff --git a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx index 2c8257529..992e7e2dd 100644 --- a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx +++ b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx @@ -102,6 +102,14 @@ describe("describeStep", () => { ]); }); + it("keeps the window sentence on a read past the lookback limit", () => { + const reach: CoachStep = { ...SLEEP, reason: "outside_reach" }; + expect(describeStep(reach, t, tCount).meta).toEqual([ + "last 30 days", + "no readings in this window", + ]); + }); + it("counts metrics on the snapshot step and rows on labs", () => { const snapshot: CoachStep = { id: "s1", diff --git a/src/lib/ai/coach/__tests__/method.test.ts b/src/lib/ai/coach/__tests__/method.test.ts index 765e75a71..4dad700cf 100644 --- a/src/lib/ai/coach/__tests__/method.test.ts +++ b/src/lib/ai/coach/__tests__/method.test.ts @@ -83,6 +83,31 @@ describe("buildMethod", () => { }); }); + it("keeps the window sentence for a read past the lookback limit", () => { + const method = buildMethod({ + steps: [ + step({ + id: "s1", + tool: "get_sleep", + domain: "sleep", + window: "last30days", + status: "empty", + reason: "outside_reach", + }), + ], + results: [], + locale: "en", + }); + expect(method?.text).toBe( + "Sleep, last 30 days: no readings in this window", + ); + expect(method?.entries[0]).toEqual({ + domain: "sleep", + window: "last30days", + absent: "outside_window", + }); + }); + it("says totals for a summed metric and a rate for compliance", () => { const method = buildMethod({ steps: [ diff --git a/src/lib/ai/coach/activity/__tests__/recorder.test.ts b/src/lib/ai/coach/activity/__tests__/recorder.test.ts index a00dcc4c9..319f34ac5 100644 --- a/src/lib/ai/coach/activity/__tests__/recorder.test.ts +++ b/src/lib/ai/coach/activity/__tests__/recorder.test.ts @@ -62,6 +62,30 @@ describe("createActivityRecorder", () => { }); }); + it("names a fetch past the lookback limit on the entry and its metadata", () => { + const { rec, frames } = recorder(); + const reach = rec.start({ + phase: "fetch", + round: 1, + ...fetchActivityLabel("en", { domain: "weight", window: "last90days" }), + stepRef: "s1", + }); + rec.finish(reach, "empty", { reason: "outside_reach" }); + const plain = rec.start({ + phase: "fetch", + round: 1, + ...fetchActivityLabel("en", { domain: "pulse", window: "last90days" }), + stepRef: "s2", + }); + rec.finish(plain, "empty"); + expect( + frames.find((f) => f.id === reach && f.status === "empty"), + ).toMatchObject({ reason: "outside_reach" }); + expect(frames.at(-1)).not.toHaveProperty("reason"); + expect(rec.meta()[0]).toMatchObject({ reason: "outside_reach" }); + expect(rec.meta()[1]).not.toHaveProperty("reason"); + }); + it("drops a title that prescribes a dose and keeps the catalog label", () => { const { rec, frames } = recorder(); const id = rec.start({ diff --git a/src/lib/ai/coach/activity/contract.ts b/src/lib/ai/coach/activity/contract.ts index d4b4d65e2..893131e50 100644 --- a/src/lib/ai/coach/activity/contract.ts +++ b/src/lib/ai/coach/activity/contract.ts @@ -22,6 +22,7 @@ import type { CoachActivityMeta, CoachActivityPhase, + CoachStep, CoachStepStatus, CoachStopReason, CoachTrail, @@ -67,6 +68,27 @@ export interface ActivityPatch { labelKey?: string; label?: string; count?: number; + /** Why a settled fetch found nothing, when it is a reason the trail names. */ + reason?: CoachActivityMeta["reason"]; +} + +/** + * How a `fetch` entry closes for the step it stands for: the step's status + * (`done` for one that never settled), its count, and `outside_reach` when + * the step says its readings all lie past the lookback limit. Any other + * step reason stays on the step. + */ +export function settledFetch( + status: CoachStepStatus, + step: Pick | null, +): [Exclude, ActivityPatch] { + return [ + status === "running" ? "done" : status, + { + ...(step?.count !== undefined ? { count: step.count } : {}), + ...(step?.reason === "outside_reach" ? { reason: step.reason } : {}), + }, + ]; } export interface ActivityRecorder { diff --git a/src/lib/ai/coach/activity/recorder.ts b/src/lib/ai/coach/activity/recorder.ts index b30ea238a..eac14e8c8 100644 --- a/src/lib/ai/coach/activity/recorder.ts +++ b/src/lib/ai/coach/activity/recorder.ts @@ -86,6 +86,7 @@ export function createActivityRecorder(args: { if (patch.count !== undefined && Number.isSafeInteger(patch.count)) { entry.meta.count = Math.max(0, patch.count); } + if (patch.reason === "outside_reach") entry.meta.reason = patch.reason; if (patch.title !== undefined) { const title = screenActivityTitle(patch.title, args.screen); if (title) entry.title = title; diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts index 091b17a52..12a420f1d 100644 --- a/src/lib/ai/coach/dialog-keys.ts +++ b/src/lib/ai/coach/dialog-keys.ts @@ -65,6 +65,8 @@ export const COACH_STEP_REASON_KEYS: Readonly> = { no_data: "coach.step.reason.noData", outside_window: "coach.step.reason.outsideWindow", + // The web trail keeps the window sentence; the answer names the limit. + outside_reach: "coach.step.reason.outsideWindow", module_disabled: "coach.step.reason.moduleDisabled", retrieval_failed: "coach.step.reason.retrievalFailed", invalid_arguments: "coach.step.reason.invalidArguments", diff --git a/src/lib/ai/coach/method.ts b/src/lib/ai/coach/method.ts index f9fad6201..3618a0794 100644 --- a/src/lib/ai/coach/method.ts +++ b/src/lib/ai/coach/method.ts @@ -145,8 +145,12 @@ function collectEntries( ...(granularity ? { granularity } : {}), }; if (step.status === "empty") { - if (step.reason && ABSENT_REASONS.has(step.reason)) { - add({ ...base, absent: step.reason as Absent }); + // Past the lookback limit the method line keeps the window sentence, + // as it always has; the answer names the limit. + const reason = + step.reason === "outside_reach" ? "outside_window" : step.reason; + if (reason && ABSENT_REASONS.has(reason)) { + add({ ...base, absent: reason as Absent }); } continue; } diff --git a/src/lib/ai/coach/step-reasons.ts b/src/lib/ai/coach/step-reasons.ts new file mode 100644 index 000000000..a79954d9a --- /dev/null +++ b/src/lib/ai/coach/step-reasons.ts @@ -0,0 +1,18 @@ +/** + * Why a Coach step found nothing or failed: the one list behind the + * `CoachStepReason` type and the `step` frame's schema. + * + * `outside_window`: no readings in the window read. `outside_reach`: readings + * exist, all of them older than the lookback limit set for the Coach; the + * answer names the limit, and a method entry says `outside_window` for it. + * + * Client-safe: no server import. + */ +export const COACH_STEP_REASONS = [ + "no_data", + "outside_window", + "outside_reach", + "module_disabled", + "retrieval_failed", + "invalid_arguments", +] as const; diff --git a/src/lib/ai/coach/stream-events.ts b/src/lib/ai/coach/stream-events.ts index db31aad96..d4bce0bb3 100644 --- a/src/lib/ai/coach/stream-events.ts +++ b/src/lib/ai/coach/stream-events.ts @@ -28,6 +28,7 @@ import { SUGGESTED_ACTION_TYPES, type CheckupIntervalId, } from "@/lib/ai/coach/suggest-action"; +import { COACH_STEP_REASONS } from "@/lib/ai/coach/step-reasons"; import { COACH_TOOL_NAMES } from "@/lib/ai/coach/tools/definitions"; import { COACH_MEMORY_CATEGORIES, @@ -100,15 +101,7 @@ export const coachStepSchema = z .int() .optional() .describe("Readings or rows the server counted. Never a health value."), - reason: z - .enum([ - "no_data", - "outside_window", - "module_disabled", - "retrieval_failed", - "invalid_arguments", - ]) - .optional(), + reason: z.enum(COACH_STEP_REASONS).optional(), resultRef: resultRefSchema .optional() .describe("The table this step produced (`r1`..), when it produced one."), @@ -123,7 +116,7 @@ export const coachStepSchema = z .meta({ id: "CoachStep", description: - "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value.", + "One thing the Coach read on a turn. Sent live as `step` frames that upsert by `id` (`running`, then `done` / `empty` / `failed`), and persisted on `metricSource.steps`. Catalog labels, domains, windows and counts only: never free text, an analyte name or a value. `reason` says why a step found nothing or failed: `outside_window` means no readings in the window read, `outside_reach` that readings exist but all lie past the lookback limit set for the Coach (the answer names the limit). A client without a string for a reason shows the step without one.", }); // ── Results ─────────────────────────────────────────────────────────────── @@ -464,12 +457,13 @@ const coachActivityMetaShape = { .describe("Readings, lookups or entries the server counted."), durationMs: z.number().int().optional(), stop: coachStopReasonSchema.optional().describe("Set on the `stop` entry."), + reason: z.enum(["outside_reach"]).optional(), }; export const coachActivityMetaSchema = z.object(coachActivityMetaShape).meta({ id: "CoachActivityMeta", 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`.", + "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`. `reason` is set only on a settled `fetch` entry whose readings all lie past the lookback limit set for the Coach (`outside_reach`), mirroring the step it stands for.", }); export const coachActivitySchema = z diff --git a/src/lib/ai/coach/turn/__tests__/steps.test.ts b/src/lib/ai/coach/turn/__tests__/steps.test.ts index 06a297fbf..cd471944a 100644 --- a/src/lib/ai/coach/turn/__tests__/steps.test.ts +++ b/src/lib/ai/coach/turn/__tests__/steps.test.ts @@ -55,6 +55,7 @@ const STATUSES = new Set(["running", "done", "empty", "failed"]); const REASONS = new Set([ "no_data", "outside_window", + "outside_reach", "module_disabled", "retrieval_failed", "invalid_arguments", @@ -218,6 +219,7 @@ function fuzzResult(r: () => number): CoachToolResult | undefined { const reasons = [ "no_data", "outside_window", + "outside_reach", "unavailable_in_scope", "no_data_unconfirmed", "retrieval_failed", @@ -416,6 +418,10 @@ describe("toStep — mapping", () => { }, }), ).toMatchObject({ status: "empty", reason: "outside_window", count: 40 }); + // Past the lookback limit: its own reason, and no figure from the rows. + const reach = at("outside_reach"); + expect(reach).toMatchObject({ status: "empty", reason: "outside_reach" }); + expect(reach).not.toHaveProperty("count"); expect(at("retrieval_failed")).toMatchObject({ status: "failed", reason: "retrieval_failed", diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 4f5ce6707..79799537b 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -54,7 +54,10 @@ import type { CoachStop, } from "@/lib/ai/coach/types"; import type { AiToolCall } from "@/lib/ai/types"; -import type { ActivityRecorder } from "@/lib/ai/coach/activity/contract"; +import { + settledFetch, + type ActivityRecorder, +} from "@/lib/ai/coach/activity/contract"; import { createActivityRecorder, type TurnActivityRecorder, @@ -518,11 +521,7 @@ export async function runTurnModel(args: { steps.record(step); const entry = fetches.get(index); const status = step?.status ?? (result.present ? "done" : "empty"); - if (entry) { - activity.finish(entry.id, status === "running" ? "done" : status, { - ...(step?.count !== undefined ? { count: step.count } : {}), - }); - } + if (entry) activity.finish(entry.id, ...settledFetch(status, step)); const reads = roundReads.get(round) ?? []; reads.push({ count: step?.count ?? 0, diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts index 09b6153df..b62ca8cea 100644 --- a/src/lib/ai/coach/turn/steps.ts +++ b/src/lib/ai/coach/turn/steps.ts @@ -119,9 +119,9 @@ const MISS: Readonly< no_data: { status: "empty", reason: "no_data" }, analyte_not_found: { status: "empty", reason: "no_data" }, outside_window: { status: "empty", reason: "outside_window" }, - // Beyond the lookback limit: to the person, "no readings in this window" - // is the true sentence; the limit itself is named in the answer. - outside_reach: { status: "empty", reason: "outside_window" }, + // Beyond the lookback limit: the readings exist, the limit keeps them + // out. Its own reason, so a trail can say so; the answer names the limit. + outside_reach: { status: "empty", reason: "outside_reach" }, module_disabled: { status: "empty", reason: "module_disabled" }, retrieval_failed: { status: "failed", reason: "retrieval_failed" }, no_data_unconfirmed: { status: "failed", reason: "retrieval_failed" }, diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 322c72c89..0f3777aa4 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -10,6 +10,7 @@ import { z } from "zod/v4"; import { locales } from "@/lib/i18n/config"; import { COACH_SCOPE_SOURCES } from "./scope-sources"; +import type { COACH_STEP_REASONS } from "./step-reasons"; import type { AiUnavailableReason } from "@/lib/ai/capabilities/types"; import type { CoachSuggestedAction } from "./suggest-action"; @@ -339,8 +340,9 @@ export interface CoachActivityMeta { /** Readings, lookups or remembered entries the server counted. */ count?: number; durationMs?: number; - /** Set on the `stop` entry. */ + /** `stop`: on the stop entry. `reason`: a fetch past the lookback limit. */ stop?: CoachStopReason; + reason?: "outside_reach"; } /** One trail entry on the wire: the metadata plus the owner-only model text. */ @@ -446,13 +448,8 @@ export type CoachStepDomain = | "day" | "snapshot"; -/** Why a step or a method entry found nothing. */ -export type CoachStepReason = - | "no_data" - | "outside_window" - | "module_disabled" - | "retrieval_failed" - | "invalid_arguments"; +/** Why a step or a method entry found nothing (`step-reasons.ts`). */ +export type CoachStepReason = (typeof COACH_STEP_REASONS)[number]; export interface CoachStep { /** `s1`..`s64`, unique within a turn; frames upsert by id. */