perf(coach): keep the system prompt stable across turns and rounds

The opener hint, the first-name clause, resolved follow-ups, the table rules
and the final-round line used to be written into the system prompt, so the
cached prefix (tools plus system) was rewritten on every turn and again on
the forced final round. They travel on the user side now: the per-turn lines
in the user message, the table rules as a note after the round that produced
the first table, the final-round reason as the last user line. The system
prompt depends on the locale, the Coach preferences and the about-me text
only.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:49:00 +02:00
1 parent c2245d1d79
commit e573f28ae8
23 files changed
+512 -101

No files matched your search

@@ -68,6 +68,18 @@ export interface ProviderCall {
}
export const providerCalls: ProviderCall[] = [];
/**
* Everything one provider call carried on the user side. Since #1217 a turn's
* own lines (a resolved follow-up, an answered clarification, a continuation)
* travel here, never in the system prompt, so the cached prefix holds.
*/
export function userSide(call: ProviderCall): string {
return call.messages
.filter((msg) => msg.role === "user")
.map((msg) => msg.content)
.join("\n\n");
}
/** The last assistant message of the stored conversation. */
export const LAST_ASSISTANT_ID = "m-a1";
export const CONVERSATION_ID = "c-dialog";
@@ -113,6 +113,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: h.m.getCoachSystemPrompt,
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: h.m.getSelfContextTextForUser,
@@ -652,21 +653,34 @@ describe("coach chat golden transcripts", () => {
expect(transcript.thrown).toBeNull();
expect(m.runStreamingRawCompletionWithFallback).toHaveBeenCalledTimes(1);
const { params } = m.runStreamingRawCompletionWithFallback.mock
.calls[0][0] as { params: { system: string } };
expect(params.system).toContain(CLARIFIED);
.calls[0][0] as {
params: { system: string; messages: Array<{ content: string }> };
};
// #1217 — on the user side, never in the cached system prompt.
const user = params.messages.map((msg) => msg.content).join("\n");
expect(user).toContain(CLARIFIED);
expect(params.system).not.toContain(CLARIFIED);
// Server-authored: the request's choice id picks the stored value,
// the label the model once wrote never rides along.
expect(params.system).not.toContain("Resting pulse");
const line = user
.split("\n")
.find((text) => text.startsWith("CLARIFIED:"));
expect(line).toContain(CLARIFIED);
expect(line).not.toContain("Resting pulse");
});
it("the tool-mode prompt carries the same line", async () => {
reply("Your resting pulse averaged 62 bpm.");
const transcript = await runTurn(post, body, {});
expect(transcript.thrown).toBeNull();
const { system } = m.runCoachToolLoop.mock.calls[0][0] as {
const { system, messages } = m.runCoachToolLoop.mock.calls[0][0] as {
system: string;
messages: Array<{ content: string }>;
};
expect(system).toContain(CLARIFIED);
expect(messages.map((msg) => msg.content).join("\n")).toContain(
CLARIFIED,
);
expect(system).not.toContain(CLARIFIED);
});
it("an unanswered turn sends neither prompt the line", async () => {
@@ -680,8 +694,13 @@ describe("coach chat golden transcripts", () => {
{},
);
const { params } = m.runStreamingRawCompletionWithFallback.mock
.calls[0][0] as { params: { system: string } };
.calls[0][0] as {
params: { system: string; messages: Array<{ content: string }> };
};
expect(params.system).not.toContain("CLARIFIED:");
expect(
params.messages.map((msg) => msg.content).join("\n"),
).not.toContain("CLARIFIED:");
});
});
});
@@ -118,6 +118,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -116,6 +116,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
@@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -265,7 +265,7 @@ describe("a reuse chip", () => {
const run = await tap("f3");
expect(run.status).toBe(200);
expect(annotated("coach.followUp.reuse_unavailable")).toHaveLength(1);
expect(providerCalls[0].system).toContain(
expect(h.userSide(providerCalls[0])).toContain(
"Call show_result with ref m1.r1 and view chart",
);
});
@@ -282,15 +282,16 @@ describe("a model chip", () => {
anchor: { domain: "glucose", window: "allTime" },
});
expect(run.status).toBe(200);
const system = providerCalls[0].system;
expect(system).toContain(
const user = h.userSide(providerCalls[0]);
expect(user).toContain(
"FOLLOW-UP: the person tapped the previous_period chip",
);
expect(system).toContain(
expect(user).toContain(
"get_metric_table metric=bp window=last30days period=previous granularity=week",
);
expect(system).toContain("m1.r1");
expect(system).not.toContain("glucose window=allTime");
expect(user).toContain("m1.r1");
expect(user).not.toContain("glucose window=allTime");
expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
});
it("degrades a chip that is not on the latest reply to a plain message", async () => {
@@ -303,7 +304,7 @@ describe("a model chip", () => {
});
expect(run.status).toBe(200);
expect(providerCalls).toHaveLength(1);
expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:");
expect(annotated("coach.followUp.stale")).toEqual([
expect.objectContaining({ meta: { reason: "not_latest" } }),
]);
@@ -313,7 +314,7 @@ describe("a model chip", () => {
conversationWithChips();
world.script = [{ text: "Here is what I see." }];
await tap("f9");
expect(providerCalls[0].system).not.toContain("FOLLOW-UP:");
expect(h.userSide(providerCalls[0])).not.toContain("FOLLOW-UP:");
expect(annotated("coach.followUp.stale")).toEqual([
expect.objectContaining({ meta: { reason: "unknown_chip" } }),
]);
@@ -343,16 +344,17 @@ describe("a model chip", () => {
{ text: "Having looked further, the readings stay level." },
];
await tap("f1");
const system = providerCalls[0].system;
expect(system).toContain(
const user = h.userSide(providerCalls[0]);
expect(user).toContain(
"CONTINUE: the person asked you to keep looking. The unfinished question is their message before that request, in CONVERSATION.",
);
// The question stays the person's own turn; it never reaches the
// system role.
expect(system).not.toContain("How was my blood pressure last month?");
expect(system).toContain(
expect(user).toContain(
"Already fetched: get_metric_table(bp, last30days, week) → m1.r1",
);
// The system role carries neither the line nor the question (#1217).
const { system } = providerCalls[0];
expect(system).not.toContain("CONTINUE:");
expect(system).not.toContain("How was my blood pressure last month?");
});
});
@@ -373,7 +375,7 @@ describe("an answered question", () => {
clarification: { messageId: h.LAST_ASSISTANT_ID, choiceId: "c2" },
});
expect(run.status).toBe(200);
expect(providerCalls[0].system).toContain(
expect(h.userSide(providerCalls[0])).toContain(
"CLARIFIED: the person answered your clarifying question by choosing metric=resting_hr.",
);
});
@@ -385,7 +387,7 @@ describe("an answered question", () => {
message: "The one from my watch",
clarification: { messageId: h.LAST_ASSISTANT_ID },
});
expect(providerCalls[0].system).toContain(
expect(h.userSide(providerCalls[0])).toContain(
"CLARIFIED: the person answered your clarifying question in their own words",
);
});
@@ -397,7 +399,7 @@ describe("an answered question", () => {
message: "Resting heart rate",
clarification: { messageId: "m-older", choiceId: "c2" },
});
expect(providerCalls[0].system).not.toContain("CLARIFIED:");
expect(h.userSide(providerCalls[0])).not.toContain("CLARIFIED:");
expect(annotated("coach.clarification.stale")).toHaveLength(1);
});
@@ -154,6 +154,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -147,6 +147,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
// NB: the outbound guard + numeric verifier are REAL — that is the point.
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(),
@@ -120,6 +120,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -153,6 +153,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),
@@ -193,6 +193,7 @@ vi.mock("@/lib/ai/coach/outbound-guard", () => ({
}));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
buildCoachTurnNotes: vi.fn(() => ""),
}));
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextTextForUser: vi.fn(async () => null),