fix(coach): restore every step a turn records on reload

The turn records up to 64 steps while the read path kept only the first
48, so the tail of a long answer's step list vanished on reload. Both ends
now read one MAX_TURN_STEPS constant, and a unit plus an integration test
round-trip a full 64-step answer through the stored provenance.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 11:42:37 +01:00
1 parent 27c8533f40
commit 28340e7c66
7 files changed
+157 -19

No files matched your search

@@ -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,
+42 -1
View File
@@ -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(
+7
View File
@@ -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;
+7 -8
View File
@@ -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<T>(raw: unknown, schema: z.ZodType<T>): 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
@@ -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;
+1 -7
View File
@@ -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<string> = new Set(
coachScopeSourceSchema.options,
@@ -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);
});
});