From 75ccc019848aeb246c27f91355048d31682117d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 22:05:14 +0200 Subject: [PATCH] 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) --- src/app/api/insights/chat/route.ts | 21 +++++++++++- tests/integration/coach-chat.test.ts | 50 ++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index db50b5b5a..4db7cb8d1 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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 } diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 06e39a3bc..2e4b04c77 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -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)( + 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); + }); });