fix(coach): graceful fallback when sentinel-only output strips empty

When the model emits only a `---KEYVALUES---` block with no leading
prose (or a malformed block missing the closing marker), the
post-strip prose is empty. The previous fallback chained back to
`rawReply`, surfacing raw sentinel markers to the user. Replace with
a structured `coach.provider.empty` error frame so the UI renders
the localised provider-error copy instead of leaking the markers.

Add a regression integration test pinning that the streamed body
never contains `---KEYVALUES---` for a sentinel-only reply, and that
no assistant message is persisted in this branch.

Closes Code-H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 22:05:14 +02:00
1 parent 363e73cbf2
commit 75ccc01984
2 files changed
+70 -1

No files matched your search

+20 -1
View File
@@ -272,7 +272,26 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// provenance envelope so the UI can render the collapsible
// "Worauf bezieht sich das?" disclosure.
const sentinel = parseKeyValuesSentinel(rawReply);
const replyText = sentinel.prose.trim() || rawReply;
const proseAfterStrip = sentinel.prose.trim();
// v1.4.22 W5 reconcile (Code-H1) — when the model emits a
// sentinel-only / malformed reply, `sentinel.prose` is empty after
// stripping. The previous fallback `sentinel.prose.trim() || rawReply`
// surfaced raw `---KEYVALUES---` markers to the user. The empty-prose
// condition signals an unusable provider response: short-circuit to
// the structured `coach.provider.empty` error frame instead of
// streaming the raw sentinel body.
if (!proseAfterStrip) {
annotate({
action: { name: "coach.keyvalues.parse_failed" },
meta: {
kept: sentinel.keyValues.length,
reason: "empty_prose_after_strip",
promptVersion: PROMPT_VERSION,
},
});
return streamProviderError({ code: "coach.provider.empty" });
}
const replyText = proseAfterStrip;
const enrichedProvenance =
sentinel.keyValues.length > 0
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
+50
View File
@@ -398,4 +398,54 @@ describe("POST /api/insights/chat — integration", () => {
};
expect(persisted.keyValues).toBeUndefined();
});
it("never leaks raw ---KEYVALUES--- markers when the provider returns a sentinel-only reply", async () => {
// v1.4.22 W5 reconcile (Code-H1) — regression test. The model
// emits ONLY the sentinel block with no leading prose AND a
// missing close marker, so `parseKeyValuesSentinel` returns
// `{ prose: "", malformed: true }`. The route must surface the
// structured `coach.provider.empty` error frame instead of
// streaming the raw sentinel body to the client.
await seedUserWithSession();
runProviderMock.mockResolvedValue({
result: {
content: [
"---KEYVALUES---",
"avg7 systolic: 138 [mmHg] (last7days)",
// Note: no closing ---END--- and no prose before the marker.
].join("\n"),
tokensUsed: 64,
model: "mock",
providerType: "openai",
},
workingProvider: { providerType: "openai", instance: {} },
fallbackHops: [],
});
const { POST } = await import("@/app/api/insights/chat/route");
const res = await (POST as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ message: "How is my BP this week?" }),
}),
);
expect(res.status).toBe(200);
const text = await readStream(res);
// Streamed body must NOT contain the raw sentinel markers and must
// surface the structured provider-empty error.
expect(text).not.toMatch(/---KEYVALUES---/);
expect(text).not.toMatch(/---END---/);
expect(text).toMatch(/"type":"error"/);
expect(text).toMatch(/coach\.provider\.empty/);
// No assistant message persisted — the empty-prose branch
// short-circuits before `appendMessage`.
const prisma = getPrismaClient();
const messages = await prisma.coachMessage.findMany({
where: { role: "assistant" },
});
expect(messages.length).toBe(0);
});
});