feat(coach): name a read past the lookback limit on the trail

A read whose readings all lie past the lookback limit was reported on its
step as outside_window, and its fetch entry carried no reason, so a client
could not tell an empty window from data the limit keeps out. The step now
carries reason outside_reach and the fetch entry mirrors it. The method
line and the web trail keep the window sentence; the answer names the
limit as before.
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 14:12:09 +01:00
1 parent 2e28d4ed4c
commit f39598f2e7
17 files changed
+212 -33

No files matched your search

@@ -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<string, unknown> | 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,
}),
@@ -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 = [
{