test(coach): pin the answered-clarification line on the local path

The line that resolves an answered clarifying question was already
appended to the system prompt on both paths; nothing proved it for the
no-tools (local model) path. Three route-level cases now do: the local
prompt and the tool-mode prompt both carry the server-resolved line, and
a turn without an answer carries neither. Removing the line from the
local prompt fails the first case.

The golden harness gains a coachMessage.findMany mock for this; it
defaults to no rows, so no existing transcript changes and the golden
snapshots are untouched.

The result-table caption test used the regex dotAll flag, which the
TypeScript target rejects (TS1501); it matches across lines with
[\s\S] instead.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:11:55 +02:00
1 parent b40de3a4d9
commit db10ba714e
3 files changed
+108 -1

No files matched your search

@@ -114,6 +114,7 @@ export const m = {
userFindUnique: vi.fn(),
userUpdate: vi.fn(),
conversationFindFirst: vi.fn(),
coachMessageFindMany: vi.fn(),
checkRateLimit: vi.fn(),
resolveServerLocale: vi.fn(),
runStreamingRawCompletionWithFallback: vi.fn(),
@@ -192,6 +193,7 @@ export function resetGolden(): void {
return {};
});
m.conversationFindFirst.mockResolvedValue({ id: "c-existing" });
m.coachMessageFindMany.mockResolvedValue([]);
m.checkRateLimit.mockResolvedValue({ allowed: true, resetAt: 0 });
m.resolveServerLocale.mockResolvedValue("en");
m.resolveProviderChain.mockResolvedValue([
@@ -62,6 +62,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: h.m.userFindUnique, update: h.m.userUpdate },
coachConversation: { findFirst: h.m.conversationFindFirst },
coachMessage: { findMany: h.m.coachMessageFindMany },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: h.m.checkRateLimit }));
@@ -574,6 +575,110 @@ describe("coach chat golden transcripts", () => {
expect(types.filter((t) => t === "step")).toHaveLength(6);
expect(JSON.stringify(transcript)).not.toContain("Ferritin");
});
// 23 ── an answered clarification ──────────────────────────────────────
describe("an answered clarification reaches the prompt on both paths", () => {
const QUESTION_ID = "msg-question";
const CLARIFIED =
"CLARIFIED: the person answered your clarifying question by choosing metric=pulse window=last30days.";
beforeEach(() => {
m.fetchConversationWithMessages.mockResolvedValue({
id: "c-existing",
summary: null,
attachmentCount: 0,
messages: [
{
role: "user",
content: "How is my pulse?",
providerType: null,
metricSource: null,
},
{
role: "assistant",
content: "Which pulse do you mean?",
providerType: "anthropic",
metricSource: null,
},
],
});
m.coachMessageFindMany.mockResolvedValue([
{
id: QUESTION_ID,
role: "assistant",
providerType: "anthropic",
metricSourceJson: JSON.stringify({
windows: [],
metrics: [],
clarification: {
kind: "metric",
choices: [
{
id: "c1",
labelKey: "coach.step.domain.snapshot",
label: "Resting pulse",
value: { metric: "pulse", window: "last30days" },
},
{
id: "c2",
labelKey: "coach.step.domain.snapshot",
label: "Heart rate variability",
value: { metric: "hrv" },
},
],
freeText: true,
},
}),
},
]);
});
const body = {
conversationId: "c-existing",
message: "Resting pulse",
clarification: { messageId: QUESTION_ID, choiceId: "c1" },
};
it("the no-tools (local) prompt carries the resolved line", async () => {
m.resolveProviderChain.mockResolvedValue(NO_TOOLS_CHAIN);
m.runStreamingRawCompletionWithFallback.mockImplementation(
streamingResult("Your resting pulse averaged 62 bpm."),
);
const transcript = await runTurn(post, body, {});
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);
// 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");
});
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 {
system: string;
};
expect(system).toContain(CLARIFIED);
});
it("an unanswered turn sends neither prompt the line", async () => {
m.resolveProviderChain.mockResolvedValue(NO_TOOLS_CHAIN);
m.runStreamingRawCompletionWithFallback.mockImplementation(
streamingResult("Your resting pulse averaged 62 bpm."),
);
await runTurn(
post,
{ conversationId: "c-existing", message: "Resting pulse" },
{},
);
const { params } = m.runStreamingRawCompletionWithFallback.mock
.calls[0][0] as { params: { system: string } };
expect(params.system).not.toContain("CLARIFIED:");
});
});
});
describe("coach chat golden transcripts — refusals before the stream", () => {
@@ -73,7 +73,7 @@ describe("CoachResultTable", () => {
<CoachResultTable result={table(3)} method="30 readings, daily means" />,
);
expect(html).toMatch(
/<caption[^>]*>.*Blood pressure by day.*30 readings, daily means.*<\/caption>/s,
/<caption[^>]*>[\s\S]*Blood pressure by day[\s\S]*30 readings, daily means[\s\S]*<\/caption>/,
);
});