mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user