From 59a435d5ac67e46bc2c2987cf3b7a9cccd0ea277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 15:03:03 +0200 Subject: [PATCH] test(coach): pin the window/absence discriminant to the reported case The fixture is the report: a fortnight of glucose readings from April 2024 on an account that displays them everywhere else. The clock is pinned so it cannot age out of the window it was written to sit outside of. Covers the four states an empty window read can resolve to, that a never-recorded metric and a historical one differ in the SAME run, that a mean is withheld across mixed units rather than fabricated, that the manifest advertises the range instead of "absent", that both instructions branch on the reason code, and that a miss carrying figures reaches the prose verifier's authoritative set. Adds the medication-compliance page to the failed-read sweep and pins the narration blocks' failure annotation. --- .../failed-read-never-empty.test.tsx | 4 + .../coach-availability-window-honesty.test.ts | 556 ++++++++++++++++++ .../ai/coach/__tests__/tools-executor.test.ts | 38 ++ src/lib/ai/coach/__tests__/tools-loop.test.ts | 52 ++ src/lib/ai/coach/tools/availability.ts | 8 +- 5 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx index ef60997ce..8b4eaf6ff 100644 --- a/src/components/__tests__/failed-read-never-empty.test.tsx +++ b/src/components/__tests__/failed-read-never-empty.test.tsx @@ -155,6 +155,10 @@ describe("read-failure branch is wired on every swept surface", () => { "src/components/custom-metrics/custom-metric-values-list.tsx", "src/app/achievements/page.tsx", "src/components/insights/nutrients/micronutrients-card.tsx", + // A failed comprehensive read used to fall through to "you haven't added + // any medications yet" — with a CTA inviting someone on a full daily + // regimen to re-enter what the app already holds. + "src/app/insights/medications/page.tsx", // The three sleep-rhythm cards share one read, so the PAGE owns the single // error notice rather than each card painting its own copy of it. "src/app/insights/sleep/page.tsx", diff --git a/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts new file mode 100644 index 000000000..63d849ec5 --- /dev/null +++ b/src/lib/ai/coach/__tests__/coach-availability-window-honesty.test.ts @@ -0,0 +1,556 @@ +/** + * A history the retrieval window cannot reach is not an absent metric (#648). + * + * The reported case is the fixture: a substantial glucose history imported from + * a fortnight in April 2024, on an account whose glucose surfaces display it + * correctly, asked about through the Coach. Every assertion here exists to keep + * the two situations distinguishable end to end — the tool result, the DATA + * INVENTORY line the model reads before it decides whether to fetch at all, and + * the two instructions that used to tell it to report absence and move on. + * + * Dates are fixed and `now` is injected, so the fixture cannot slide out of its + * window as the calendar moves. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; + +const buildCoachSnapshot = + vi.fn<(userId: string, scope?: unknown) => Promise>(); +vi.mock("@/lib/ai/coach/snapshot", () => ({ + buildCoachSnapshot: (userId: string, scope?: unknown) => + buildCoachSnapshot(userId, scope), +})); + +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: () => Promise.resolve({ present: false }), +})); +vi.mock("@/lib/ai/coach/illness-snapshot", () => ({ + buildIllnessScores: () => Promise.resolve(null), +})); +vi.mock("@/lib/cycle/gate", () => ({ + isCycleAvailableForUser: () => Promise.resolve(false), +})); + +// The probe is the point of the fix, so it runs for real against a stubbed +// Prisma. Every aggregate the probe can issue is scripted here. +interface GroupRow { + type: string; + unit: string | null; + _count: { _all: number }; + _min: { measuredAt: Date | null; value: number | null }; + _max: { measuredAt: Date | null; value: number | null }; + _avg: { value: number | null }; +} +let measurementRows: GroupRow[] = []; +let measurementThrows = false; +const workoutAggregate = vi.fn(); +const moodAggregate = vi.fn(); +const intakeAggregate = vi.fn(); +const labAggregate = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + groupBy: (args: { where: { type: { in: string[] } } }) => { + if (measurementThrows) { + return Promise.reject(new Error("probe down")); + } + const wanted = new Set(args.where.type.in); + return Promise.resolve( + measurementRows.filter((r) => wanted.has(r.type)), + ); + }, + }, + workout: { aggregate: (a: unknown) => workoutAggregate(a) }, + moodEntry: { aggregate: (a: unknown) => moodAggregate(a) }, + medicationIntakeEvent: { aggregate: (a: unknown) => intakeAggregate(a) }, + labResult: { aggregate: (a: unknown) => labAggregate(a) }, + }, +})); + +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: () => Promise.resolve("UTC"), +})); + +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { + buildCoachDataInventory, + renderDataInventory, +} from "@/lib/ai/coach/tools/inventory"; +import { buildToolModeAddendum } from "@/lib/ai/coach/tools/system-addendum"; +import { + classifyAvailability, + probeCoachAvailability, +} from "@/lib/ai/coach/tools/availability"; + +/** The reported import: a fortnight of readings, well over a year old. */ +const HISTORIC_GLUCOSE: GroupRow = { + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + _count: { _all: 1597 }, + _min: { measuredAt: new Date("2024-04-03T06:12:00Z"), value: 61 }, + _max: { measuredAt: new Date("2024-04-17T21:40:00Z"), value: 243 }, + _avg: { value: 118.4 }, +}; + +/** A reading day inside the searched window, for the in-scope branch. */ +function recentRow(type: string, daysAgo: number, now: Date): GroupRow { + const at = new Date(now.getTime() - daysAgo * 86_400_000); + return { + type, + unit: "bpm", + _count: { _all: 42 }, + _min: { measuredAt: at, value: 52 }, + _max: { measuredAt: at, value: 71 }, + _avg: { value: 61 }, + }; +} + +const NOW = new Date("2026-07-26T09:00:00Z"); + +function snapshot(sections: Record): CoachSnapshotResult { + return { + snapshotJson: JSON.stringify(sections), + sections, + provenance: { windows: [], metrics: [] }, + referenceGrounding: null, + }; +} + +function emptyAggregate() { + return { + _count: { _all: 0 }, + _min: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + _max: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + }; +} + +beforeEach(() => { + // The clock is pinned so the fixture cannot age out of its window: the + // executor's probe reads `new Date()` (there is no request-scoped clock on + // that path), and a relative-date fixture that drifts past 365 days would + // start asserting the opposite of what it was written to prove. + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + buildCoachSnapshot.mockReset(); + measurementRows = []; + measurementThrows = false; + for (const fn of [ + workoutAggregate, + moodAggregate, + intakeAggregate, + labAggregate, + ]) { + fn.mockReset(); + fn.mockResolvedValue(emptyAggregate()); + } +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("the reported case: a metric whose whole history is outside the window", () => { + it("reports outside_window with the range and a bounded aggregate, NOT no_data", async () => { + measurementRows = [HISTORIC_GLUCOSE]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: JSON.stringify({ window: "allTime" }), + }); + + expect(result.present).toBe(false); + expect(result.reason).toBe("outside_window"); + expect(result.searchedWindow).toBe("allTime"); + expect(result.available).toMatchObject({ + count: 1597, + firstDate: "2024-04-03", + lastDate: "2024-04-17", + // Older than any window reaches — so there is nothing to re-call with, + // and the honest sentence is the whole answer. + reachableWithWindow: null, + }); + expect(result.available?.series).toEqual([ + { + series: "BLOOD_GLUCOSE", + unit: "mg/dL", + count: 1597, + mean: 118.4, + min: 61, + max: 243, + }, + ]); + }); + + it("keeps a never-recorded metric distinguishable from the same run", async () => { + measurementRows = [HISTORIC_GLUCOSE]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const historic = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: JSON.stringify({ window: "allTime" }), + }); + const neverRecorded = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "hrv", window: "allTime" }), + }); + + // Both are misses; they are NOT the same miss. Collapsing the two reasons + // back into one literal is the defect, and this is the assertion that + // catches it. + expect(historic.present).toBe(false); + expect(neverRecorded.present).toBe(false); + expect(neverRecorded.reason).toBe("no_data"); + expect(neverRecorded.available).toBeUndefined(); + expect(historic.reason).not.toBe(neverRecorded.reason); + }); + + it("names the window to re-call with when one would reach the rows", async () => { + measurementRows = [ + { + type: "WEIGHT", + unit: "kg", + _count: { _all: 240 }, + _min: { measuredAt: new Date("2025-09-01T07:00:00Z"), value: 79.1 }, + _max: { measuredAt: new Date("2026-01-07T07:00:00Z"), value: 84.6 }, + _avg: { value: 82 }, + }, + ]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "weight", window: "last30days" }), + }); + + expect(result.reason).toBe("outside_window"); + expect(result.available?.reachableWithWindow).toBe("lastYear"); + }); + + it("does not call rows inside the window 'outside' it", async () => { + measurementRows = [recentRow("PULSE", 2, NOW)]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_metric_series", + rawArguments: JSON.stringify({ metric: "pulse", window: "last30days" }), + }); + + // Rows exist, recent, and the domain still produced no block — a module is + // off or a floor was not met. Widening would not help, and neither would + // telling the person they have no pulse readings. + expect(result.reason).toBe("unavailable_in_scope"); + expect(result.available?.count).toBe(42); + }); + + it("withholds a mean it cannot compute honestly across mixed units", async () => { + measurementRows = [ + HISTORIC_GLUCOSE, + { + type: "BLOOD_GLUCOSE", + unit: "mmol/L", + _count: { _all: 120 }, + _min: { measuredAt: new Date("2024-03-01T06:00:00Z"), value: 3.9 }, + _max: { measuredAt: new Date("2024-03-20T21:00:00Z"), value: 12.1 }, + _avg: { value: 6.4 }, + }, + ]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: JSON.stringify({ window: "allTime" }), + }); + + expect(result.reason).toBe("outside_window"); + expect(result.available?.count).toBe(1717); + expect(result.available?.firstDate).toBe("2024-03-01"); + expect(result.available?.series).toBeUndefined(); + }); + + it("does not assert an absence it could not establish", async () => { + measurementThrows = true; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_glucose_panel", + rawArguments: "{}", + }); + + expect(result.present).toBe(false); + expect(result.reason).toBe("no_data_unconfirmed"); + expect(result.available).toBeUndefined(); + }); + + it("probes the workouts domain against its own table, not Measurement", async () => { + workoutAggregate.mockResolvedValue({ + _count: { _all: 88 }, + _min: { startedAt: new Date("2023-01-09T17:00:00Z") }, + _max: { startedAt: new Date("2023-11-30T18:30:00Z") }, + }); + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const result = await executeCoachTool({ + userId: "u1", + name: "get_workouts", + rawArguments: JSON.stringify({ window: "last90days" }), + }); + + expect(result.reason).toBe("outside_window"); + expect(result.available).toMatchObject({ + count: 88, + firstDate: "2023-01-09", + lastDate: "2023-11-30", + }); + // No value column on a workout row, so no fabricated series aggregate. + expect(result.available?.series).toBeUndefined(); + }); + + it("leaves a derived domain's honest absence alone (nothing to probe)", async () => { + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const result = await executeCoachTool({ + userId: "u1", + name: "get_cycle", + rawArguments: "{}", + }); + expect(result.reason).toBe("no_data"); + expect(result.available).toBeUndefined(); + }); +}); + +describe("no empty window read can bypass the probe again", () => { + it("the executor returns no hand-written no_data for a missing section", () => { + const source = readFileSync( + join(process.cwd(), "src/lib/ai/coach/tools/executor.ts"), + "utf8", + ); + // Every miss goes through `emptyRead(...)`. A hand-written literal is how + // the defect got in: four sites, one reason, two meanings. + expect(source).not.toMatch( + /return \{ present: false, reason: "no_data" \}/, + ); + expect(source).not.toMatch(/reason: "no_data",\s*\}/); + }); +}); + +describe("classifyAvailability", () => { + it("splits on whether the searched window already covered the rows", () => { + const base = { count: 10, firstDate: "2024-01-01", lastDate: "2024-01-31" }; + expect( + classifyAvailability({ ...base, reachableWithWindow: null }, "allTime"), + ).toBe("outside_window"); + expect( + classifyAvailability( + { ...base, reachableWithWindow: "lastYear" }, + "last30days", + ), + ).toBe("outside_window"); + expect( + classifyAvailability( + { ...base, reachableWithWindow: "last7days" }, + "last30days", + ), + ).toBe("unavailable_in_scope"); + // `allTime` is capped at a year, so `lastYear` rows are already inside it. + expect( + classifyAvailability( + { ...base, reachableWithWindow: "lastYear" }, + "allTime", + ), + ).toBe("unavailable_in_scope"); + }); +}); + +describe("probeCoachAvailability", () => { + it("returns no entry for a domain the record is empty for", async () => { + measurementRows = []; + const probed = await probeCoachAvailability( + "u1", + new Map([["glucose", { kind: "measurement", types: ["BLOOD_GLUCOSE"] }]]), + { now: NOW }, + ); + expect(probed.size).toBe(0); + }); + + it("merges the two blood-pressure series into one domain answer", async () => { + measurementRows = [ + { + type: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + _count: { _all: 300 }, + _min: { measuredAt: new Date("2022-02-01T06:00:00Z"), value: 108 }, + _max: { measuredAt: new Date("2022-12-20T06:00:00Z"), value: 162 }, + _avg: { value: 131 }, + }, + { + type: "BLOOD_PRESSURE_DIA", + unit: "mmHg", + _count: { _all: 298 }, + _min: { measuredAt: new Date("2022-02-03T06:00:00Z"), value: 64 }, + _max: { measuredAt: new Date("2022-12-19T06:00:00Z"), value: 101 }, + _avg: { value: 84 }, + }, + ]; + const probed = await probeCoachAvailability( + "u1", + new Map([ + [ + "bp", + { + kind: "measurement", + types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + }, + ], + ]), + { now: NOW }, + ); + const bp = probed.get("bp"); + expect(bp?.count).toBe(598); + expect(bp?.firstDate).toBe("2022-02-01"); + expect(bp?.lastDate).toBe("2022-12-20"); + // Two series, each with its own aggregate — never one merged mean. + expect(bp?.series?.map((s) => s.series)).toEqual([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + ]); + }); +}); + +describe("DATA INVENTORY — the manifest the model reads before it fetches", () => { + it("advertises the stored history instead of calling the domain absent", async () => { + measurementRows = [HISTORIC_GLUCOSE]; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + + const inventory = await buildCoachDataInventory("u1", { + sources: [], + window: "allTime", + }); + const glucose = inventory.entries.find((e) => e.domain === "glucose"); + expect(glucose?.present).toBe(false); + expect(glucose?.availability).toMatchObject({ + state: "outside_window", + count: 1597, + firstDate: "2024-04-03", + lastDate: "2024-04-17", + reachableWithWindow: null, + }); + + const rendered = renderDataInventory(inventory); + expect(rendered).toContain("glucose: OUTSIDE WINDOW"); + expect(rendered).toContain("1597 readings, 2024-04-03 to 2024-04-17"); + // A metric the record genuinely lacks still reads plainly absent. + expect(rendered).toMatch(/heart-rate variability: absent/); + }); + + it("leaves a genuinely empty account's manifest unchanged", async () => { + measurementRows = []; + buildCoachSnapshot.mockResolvedValue(snapshot({})); + const inventory = await buildCoachDataInventory("u1", { + sources: [], + window: "last30days", + }); + expect(inventory.entries.every((e) => e.availability === undefined)).toBe( + true, + ); + expect(renderDataInventory(inventory)).not.toContain("OUTSIDE WINDOW"); + }); +}); + +describe("the two instructions that told the model to move on", () => { + it("the inventory preamble no longer equates present:false with no data", () => { + const rendered = renderDataInventory({ + entries: [], + restMode: false, + cycleEnabled: false, + window: "last30days", + probeScope: { sources: [] }, + }); + expect(rendered).not.toMatch( + /say plainly you have no data for it and pivot/, + ); + expect(rendered).toContain("outside window"); + expect(rendered).toContain("that is not absence"); + }); + + for (const locale of ["en", "de"] as const) { + it(`the tool-mode addendum (${locale}) branches on the reason code`, () => { + const addendum = buildToolModeAddendum(locale); + expect(addendum).toContain("outside_window"); + expect(addendum).toContain("no_data_unconfirmed"); + expect(addendum).toContain("unavailable_in_scope"); + expect(addendum).toContain("reachableWithWindow"); + }); + + it(`the tool-mode addendum (${locale}) forbids swapping the metric`, () => { + const addendum = buildToolModeAddendum(locale); + const forbids = + locale === "en" + ? /never answer a question about one metric by reporting a different one/i + : /beantworte eine Frage zu einer Metrik niemals/i; + expect(addendum).toMatch(forbids); + }); + } +}); + +/** + * The other half of the same missing distinction, from the builder side: three + * cross-cutting narration blocks that failed to `null` indistinguishably from a + * user who honestly has none. `signalTrust` is the block that tells the model how + * much to trust the numbers it is about to narrate, so a silent failure means the + * Coach reasons at full confidence over data it was supposed to caveat. + * + * Structural, because the builder's own reads are not drivable from a unit test: + * the assertion is that no cross-cutting block swallows its failure into a bare + * `null` any more. + */ +describe("a failed narration block is counted, not silently absent", () => { + const source = readFileSync( + join(process.cwd(), "src/lib/ai/coach/snapshot.ts"), + "utf8", + ); + + it("routes every cross-cutting block failure through the annotator", () => { + for (const block of [ + "buildAdherenceStoryline", + "buildChangepointSignals", + "buildSignalTrust", + "buildExperimentOutcomeBlock", + ]) { + const at = source.indexOf(block + "("); + expect(at, `${block} call site`).toBeGreaterThan(-1); + const tail = source.slice(at, at + 260); + expect(tail, `${block} swallows its failure`).toContain("blockFailed("); + } + }); + + it("keeps no bare catch-to-null on those blocks", () => { + expect(source).not.toMatch( + /buildSignalTrust\([^)]*\)\.catch\(\(\) => null\)/, + ); + expect(source).not.toMatch( + /buildChangepointSignals\([^)]*\)\.catch\(\(\) => null\)/, + ); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 2c6c93551..c4fdd18e9 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -16,6 +16,44 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot(userId, scope), })); +// v1.32.40 (#648) — an empty window read is no longer reported as absence until +// the availability probe has confirmed the record is empty. Stub the probe's +// reads to "nothing stored", which is what these dispatch assertions mean by an +// absent section; `coach-availability-window-honesty.test.ts` owns the other +// three states. +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { groupBy: () => Promise.resolve([]) }, + workout: { aggregate: () => Promise.resolve(emptyAggregate()) }, + moodEntry: { aggregate: () => Promise.resolve(emptyAggregate()) }, + medicationIntakeEvent: { + aggregate: () => Promise.resolve(emptyAggregate()), + }, + labResult: { aggregate: () => Promise.resolve(emptyAggregate()) }, + }, +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: () => Promise.resolve("UTC"), +})); + +function emptyAggregate() { + return { + _count: { _all: 0 }, + _min: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + _max: { + startedAt: null, + moodLoggedAt: null, + scheduledFor: null, + takenAt: null, + }, + }; +} + const readCoachCorrelations = vi.fn(); vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 91cf0fbe6..1d1e2c899 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -248,6 +248,58 @@ describe("round bounds (D5)", () => { // v1.21.0 (D5-1) — the loop threads the turn's shared snapshot scope to every // tool so each per-tool read lands one cache key (one snapshot build per turn). describe("shared snapshot scope threading (D5-1)", () => { + it("retains an out-of-window miss's availability payload for grounding", async () => { + // A miss carries no `data`, so the pre-#648 filter (`if (present)`) dropped + // it — and the figures the model is now told it MAY cite would have been + // absent from the authoritative set, which is how a true sentence gets + // elided as a hallucination. The miss rides `toolResults` when, and only + // when, it actually delivered figures. + executeCoachTool.mockResolvedValueOnce({ + present: false, + reason: "outside_window", + searchedWindow: "allTime", + available: { + count: 1597, + firstDate: "2024-04-03", + lastDate: "2024-04-17", + reachableWithWindow: null, + }, + }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [{ id: "c1", name: "get_glucose_panel", arguments: "{}" }], + }), + ) + .mockResolvedValueOnce(completion({ content: "done" })); + const out = await runCoachToolLoop(baseArgs); + expect(out.toolResults).toHaveLength(1); + expect(out.toolResults[0].available).toMatchObject({ count: 1597 }); + expect(out.toolTrace).toEqual([ + { name: "get_glucose_panel", present: false }, + ]); + }); + + it("still drops a miss that delivered no figures", async () => { + executeCoachTool.mockResolvedValueOnce({ + present: false, + reason: "no_data", + }); + runRawCompletionWithFallback + .mockResolvedValueOnce( + completion({ + content: "", + finishReason: "tool_calls", + toolCalls: [{ id: "c1", name: "get_glucose_panel", arguments: "{}" }], + }), + ) + .mockResolvedValueOnce(completion({ content: "done" })); + const out = await runCoachToolLoop(baseArgs); + expect(out.toolResults).toEqual([]); + }); + it("passes sharedScope through to executeCoachTool", async () => { executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } }); const shared = { diff --git a/src/lib/ai/coach/tools/availability.ts b/src/lib/ai/coach/tools/availability.ts index 36f767010..5051a2fbb 100644 --- a/src/lib/ai/coach/tools/availability.ts +++ b/src/lib/ai/coach/tools/availability.ts @@ -63,7 +63,7 @@ export type CoachAvailabilitySubject = | { kind: "lab" }; /** Per-series aggregate over the whole recorded history of one series. */ -export interface CoachAvailabilitySeries { +interface CoachAvailabilitySeries { /** The measurement type, verbatim — unambiguous for the model. */ series: string; /** The unit every row of this series carries, when they all agree. */ @@ -347,7 +347,7 @@ export async function probeCoachAvailability( * an empty window read. Frozen so a future reader (a test, a dashboard) can * assert the three states stay three. */ -export const EMPTY_READ_REASONS = { +const EMPTY_READ_REASONS = { /** Probe confirmed: the record holds nothing for this domain, ever. */ none: "no_data", /** Rows exist, all of them outside the window that was searched. */ @@ -382,7 +382,7 @@ export function classifyAvailability( } /** A `present: false` result with the window/history discriminant resolved. */ -export interface EmptyReadResult { +interface EmptyReadResult { present: false; reason: string; /** The window the empty read searched, so the miss is self-describing. */ @@ -453,7 +453,7 @@ export async function resolveEmptyRead(args: { * The subject a `get_metric_series` metric is checked against. `mood` reads * MoodEntry; every other series-backed source reads `Measurement`. */ -export function subjectForMetricSource( +function subjectForMetricSource( metric: CoachScopeSource, ): CoachAvailabilitySubject | null { if (metric === "mood") return { kind: "mood" };