diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 81a01b793..5a3506c2e 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2897, - "total": 9249, + "total": 9247, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1737,7 +1737,7 @@ "lib/ai/coach/turn/reply-guards.ts": 18, "lib/ai/coach/turn/reuse-turn.ts": 1, "lib/ai/coach/turn/sse.ts": 10, - "lib/ai/coach/turn/steps.ts": 3, + "lib/ai/coach/turn/steps.ts": 1, "lib/ai/coach/turn/types.ts": 17, "lib/ai/coach/types.ts": 58, "lib/ai/coach/visits-snapshot.ts": 1, diff --git a/src/lib/ai/coach/__tests__/persistence.test.ts b/src/lib/ai/coach/__tests__/persistence.test.ts index 5e555e918..0075ef193 100644 --- a/src/lib/ai/coach/__tests__/persistence.test.ts +++ b/src/lib/ai/coach/__tests__/persistence.test.ts @@ -27,7 +27,8 @@ import { summariseTitle, } from "../persistence"; import { encryptToBytes } from "../bytes-codec"; -import type { CoachProvenance } from "../types"; +import { MAX_TURN_STEPS } from "../activity/contract"; +import type { CoachProvenance, CoachStep } from "../types"; describe("summariseTitle", () => { it("returns the input unchanged when below 80 chars", () => { @@ -212,6 +213,46 @@ describe("appendMessage — provenance round-trip", () => { expect(out.metricSource).toEqual(envelope); }); + it("restores every step the turn may record, and none past the cap", async () => { + const step = (n: number): CoachStep => ({ + id: `s${n}`, + tool: "get_metric_series", + labelKey: "coach.step.readWindow", + label: "Read the series", + domain: "bp", + window: "last30days", + status: "done", + count: n, + }); + const recorded = Array.from({ length: MAX_TURN_STEPS }, (_, i) => + step(i + 1), + ); + stubEchoCreate(); + const full = await appendMessage({ + conversationId: "conv_1", + role: "assistant", + content: "A long answer.", + metricSource: { windows: [], metrics: [], steps: recorded }, + }); + expect(full.metricSource?.steps).toEqual(recorded); + + // A stored blob longer than the cap is cut at the cap, not before it. + stubEchoCreate( + JSON.stringify({ + windows: [], + metrics: [], + steps: [...recorded, step(MAX_TURN_STEPS + 1)], + }), + ); + const over = await appendMessage({ + conversationId: "conv_1", + role: "assistant", + content: "An over-long blob.", + metricSource: { windows: [], metrics: [] }, + }); + expect(over.metricSource?.steps).toEqual(recorded); + }); + it("tolerates a legacy blob without the new fields (no throw, fields absent)", async () => { // A pre-feature row: only the original windows/metrics envelope on disk. stubEchoCreate( diff --git a/src/lib/ai/coach/activity/contract.ts b/src/lib/ai/coach/activity/contract.ts index cca931799..d4b4d65e2 100644 --- a/src/lib/ai/coach/activity/contract.ts +++ b/src/lib/ai/coach/activity/contract.ts @@ -41,6 +41,13 @@ export const ACTIVITY_TEXT_MAX_CHARS = 400; /** `a1`..`a99`: the 100th entry of a turn is not recorded. */ export const ACTIVITY_MAX_ENTRIES = 99; +/** + * `s1`..`s64`: the live steps of one turn (four per round of the longest + * turn). Later tool calls still run but show no step. The turn records up to + * this many and a reload restores up to this many, from this one constant. + */ +export const MAX_TURN_STEPS = 64; + export interface ActivityStart { phase: CoachActivityPhase; round: number; diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index 787d4e09f..a40ffd939 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -28,7 +28,11 @@ import { coachStopSchema, coachTrailSchema, } from "./stream-events"; -import { ACTIVITY_MAX_ENTRIES, TRAIL_MAX_BYTES } from "./activity/contract"; +import { + ACTIVITY_MAX_ENTRIES, + MAX_TURN_STEPS, + TRAIL_MAX_BYTES, +} from "./activity/contract"; import { commitTurnWrites } from "./memory/turn-writes"; import { COACH_CONVERSATION_TITLE_MAX } from "./types"; import { isRedundantViewChip } from "./follow-ups/view-chip"; @@ -56,7 +60,6 @@ import type { CoachResultEntry, CoachResultMeta, CoachResultTable, - CoachStep, CoachStepDomain, CoachSuggestion, CoachTrail, @@ -225,11 +228,6 @@ function restoreOne(raw: unknown, schema: z.ZodType): T | undefined { return parsed.success ? parsed.data : undefined; } -/** The live steps a turn persisted (at most 48 per turn since v1.41). */ -function restoreSteps(raw: unknown): CoachStep[] | undefined { - return restoreEach(raw, coachStepSchema, 48); -} - /** The method line; dropped whole when it does not parse. */ function restoreMethod(raw: unknown): CoachMethod | undefined { const parsed = coachMethodSchema.safeParse(raw); @@ -341,7 +339,8 @@ function provenanceFromJson(raw: string | null): CoachProvenance | null { const toolCalls = restoreToolCalls(parsed.toolCalls); // v1.39.4 — the dialog fields: steps, method, table metadata, chips, the // clarification choices, and the forced-answer marker. - const steps = restoreSteps(parsed.steps); + // Up to the same cap the turn records steps under. + const steps = restoreEach(parsed.steps, coachStepSchema, MAX_TURN_STEPS); const method = restoreMethod(parsed.method); const results = restoreResultMetas(parsed.results); // A reply stored before view chips were withheld for a table the answer diff --git a/src/lib/ai/coach/turn/__tests__/steps.test.ts b/src/lib/ai/coach/turn/__tests__/steps.test.ts index b5453ed75..06a297fbf 100644 --- a/src/lib/ai/coach/turn/__tests__/steps.test.ts +++ b/src/lib/ai/coach/turn/__tests__/steps.test.ts @@ -34,7 +34,8 @@ import { } from "@/lib/ai/coach/tools/definitions"; import type { CoachToolResult } from "@/lib/ai/coach/tools/run/types"; -import { MAX_TURN_STEPS, snapshotStep, toStep } from "../steps"; +import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract"; +import { snapshotStep, toStep } from "../steps"; const SOURCES = coachScopeSourceSchema.options; const WINDOWS = coachScopeWindowSchema.options; diff --git a/src/lib/ai/coach/turn/steps.ts b/src/lib/ai/coach/turn/steps.ts index 87aa40787..09b6153df 100644 --- a/src/lib/ai/coach/turn/steps.ts +++ b/src/lib/ai/coach/turn/steps.ts @@ -38,13 +38,7 @@ import { } from "@/lib/ai/coach/tools/definitions"; import { isTurnResultRef } from "@/lib/ai/coach/results/refs"; import { COMPARE_SERIES_TOOL_NAME } from "@/lib/ai/coach/tools/compare-series"; - -/** - * At most this many steps per turn; later calls run but show no step. - * v1.41 — four per round of the longest turn: 48 for twelve rounds, 64 since - * v1.41.2 raised the cap to sixteen. - */ -export const MAX_TURN_STEPS = 64; +import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract"; const SCOPE_SOURCES: ReadonlySet = new Set( coachScopeSourceSchema.options, diff --git a/tests/integration/coach-steps-round-trip.test.ts b/tests/integration/coach-steps-round-trip.test.ts new file mode 100644 index 000000000..0abdf9906 --- /dev/null +++ b/tests/integration/coach-steps-round-trip.test.ts @@ -0,0 +1,96 @@ +/** + * The steps of a long Coach answer survive a reload against a real Postgres. + * + * The turn records up to `MAX_TURN_STEPS` steps on `metricSource.steps`; the + * read path restores them from `coach_messages.metric_source_json`. Both ends + * read the one constant, so a full turn's worth comes back whole and an + * over-long stored blob is cut at that cap, not before it. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { MAX_TURN_STEPS } from "@/lib/ai/coach/activity/contract"; +import { + appendMessage, + createConversation, + fetchConversationWithMessages, +} from "@/lib/ai/coach/persistence"; +import type { CoachStep } from "@/lib/ai/coach/types"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +function step(n: number): CoachStep { + return { + id: `s${n}`, + tool: "get_metric_series", + labelKey: "coach.step.readWindow", + label: "Read the last 30 days", + domain: "bp", + window: "last30days", + status: "done", + count: n, + }; +} + +const RECORDED = Array.from({ length: MAX_TURN_STEPS }, (_, i) => step(i + 1)); + +async function seedConversation(): Promise<{ + userId: string; + conversationId: string; +}> { + const user = await getPrismaClient().user.create({ + data: { username: "steps-owner", role: "USER" }, + }); + const convo = await createConversation({ userId: user.id, title: "long" }); + return { userId: user.id, conversationId: convo.id }; +} + +describe("Coach steps across a reload", () => { + it("restores every step of a full turn", async () => { + const { userId, conversationId } = await seedConversation(); + const reply = await appendMessage({ + conversationId, + role: "assistant", + content: "A long answer.", + metricSource: { windows: [], metrics: [], steps: RECORDED }, + }); + + const reloaded = await fetchConversationWithMessages( + userId, + conversationId, + ); + const message = reloaded?.messages.find((m) => m.id === reply.id); + expect(message?.metricSource?.steps).toHaveLength(MAX_TURN_STEPS); + expect(message?.metricSource?.steps).toEqual(RECORDED); + }); + + it("cuts an over-long stored blob at the cap, not before it", async () => { + const { userId, conversationId } = await seedConversation(); + const reply = await appendMessage({ + conversationId, + role: "assistant", + content: "An over-long blob.", + metricSource: { windows: [], metrics: [] }, + }); + await getPrismaClient().coachMessage.update({ + where: { id: reply.id }, + data: { + metricSourceJson: JSON.stringify({ + windows: [], + metrics: [], + steps: [...RECORDED, step(MAX_TURN_STEPS + 1)], + }), + }, + }); + + const reloaded = await fetchConversationWithMessages( + userId, + conversationId, + ); + const message = reloaded?.messages.find((m) => m.id === reply.id); + expect(message?.metricSource?.steps).toEqual(RECORDED); + }); +});