From 8b7648bdd0d29aeaafc14adc74d52f9f2200a421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 10:26:10 +0200 Subject: [PATCH 1/3] fix(coach): never 500 on a provider failure; fix Codex tool-call wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Codex backend 400 on the richer Coach tool-call request (and the daily briefing) propagated out of the provider chain runner unwrapped: a 4xx other than 401/403/429 was not classified as a hard provider failure, so it bypassed AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain status-card completions, which never send a tools array, were unaffected. Harden the failure path so a provider error can never surface as a 500: - Treat any 4xx a provider wire returns as a hard failure so it cascades and is wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422 surface) stays the only non-hard class, so that diagnostic is preserved. - The chat route now also classifies a tagged provider error that reaches its catch unwrapped and emits the graceful coach.provider.* SSE frame instead of rethrowing. Only an untagged error keeps the 500 + error-reporter path. Make the Codex 400 diagnosable: fold the redacted upstream response body into the thrown error message and the chain wide-event, so the field the backend rejects is visible in logs (it was previously dropped). Fix the Codex request shape behind the tool-call 400: - Send the required strict flag on each Responses-API function tool. - Replay prior assistant turns as output_text, not input_text, per the Responses API multi-turn contract — the violation only surfaced on round two of the tool loop, which is the path that broke. Codex-only; the OpenAI, Anthropic, local and mock providers are untouched. --- .../chat/__tests__/route-tool-mode.test.ts | 28 +++++++ src/app/api/insights/chat/route.ts | 54 ++++++++++++ src/lib/ai/__tests__/codex-client.test.ts | 84 +++++++++++++++++++ src/lib/ai/__tests__/provider-runner.test.ts | 58 ++++++++++++- src/lib/ai/codex-client.ts | 80 +++++++++++++----- src/lib/ai/provider-runner.ts | 47 +++++++++-- 6 files changed, 321 insertions(+), 30 deletions(-) diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index e09dc97a2..aa53e254b 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -261,6 +261,34 @@ describe("coach chat — tool-mode routing (F1)", () => { ); }); + it("surfaces a graceful provider-error stream (NOT an HTTP 500) when the tool loop throws a tagged provider error", async () => { + // v1.21.3 — RCA for the live incident. A Codex 400 raised inside the tool + // loop used to bubble out un-wrapped and rethrow as an HTTP 500. The route + // must now classify any tagged provider error (`upstream` + `httpStatus`) + // and return the graceful 200 SSE error frame the drawer decodes. + resolveProviderChain.mockResolvedValue([ + { providerType: "codex", instance: {} }, + ]); + (runCoachToolLoop as ReturnType).mockRejectedValueOnce( + Object.assign(new Error("Codex request failed (400): {…}"), { + httpStatus: 400, + upstream: "codex", + }), + ); + + const res = await post(chatReq({ message: "How is my BP?" })); + + // No throw → no 500. The provider-error frame streams over a 200. + expect(res.status).toBe(200); + // The full reservation is refunded (no tokens billed on a failed turn). + expect(reconcileSpend).toHaveBeenCalledWith( + "u1", + 3000, + 0, + expect.anything(), + ); + }); + it("persists the tool trace onto provenance", async () => { resolveProviderChain.mockResolvedValue([ { providerType: "anthropic", instance: {} }, diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 873ed71d9..5b2f9bdeb 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -672,6 +672,30 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch : "coach.provider.unavailable", }); } + // v1.21.3 — defence in depth. The chain runner wraps every hard provider + // failure in `AllProvidersFailedError`, but a provider client can still + // throw a tagged wire error that reaches here un-wrapped (e.g. a Codex 400 + // raised mid tool-loop on a path the chain runner did not catch). Such an + // error is a PROVIDER failure, not a server bug — surface the same graceful + // `coach.provider.*` frame the chain path uses rather than rethrowing into + // an HTTP 500 (the bug that took the live Coach down for codex users). Only + // a genuinely unexpected error (no upstream tag, no httpStatus) keeps the + // 500 + GlitchTip path so real defects stay visible. + const providerError = classifyBubblingProviderError(err); + if (providerError) { + annotate({ + action: { name: "insights.coach.providerFailed" }, + meta: { + attempts: 1, + firstStatus: providerError.httpStatus, + credentialExpired: providerError.code === "credential_expired", + unwrapped: true, + }, + }); + return streamProviderError({ + code: `coach.provider.${providerError.code}`, + }); + } throw err; } @@ -1073,6 +1097,36 @@ async function streamRefusal(args: { return new Response(stream, { status: 200, headers: SSE_HEADERS }); } +/** + * v1.21.3 — classify a provider error that bubbled out of the chain runner + * un-wrapped (i.e. not an `AllProvidersFailedError`). The provider clients tag + * their thrown errors with `upstream` + `httpStatus`; a tagged error is a + * provider failure that must surface a graceful `coach.provider.*` frame rather + * than rethrow into an HTTP 500. Returns `null` for anything that is NOT a + * recognisable provider error (a real server bug), so those keep the 500 + + * GlitchTip path. Status mapping mirrors `AllProvidersFailedError`: 401/403 → + * credential_expired, 429 → rate_limited, everything else → unavailable. + */ +function classifyBubblingProviderError( + err: unknown, +): { + code: "credential_expired" | "rate_limited" | "unavailable"; + httpStatus: number | null; +} | null { + if (err === null || typeof err !== "object") return null; + const e = err as { upstream?: unknown; httpStatus?: unknown }; + const hasUpstreamTag = typeof e.upstream === "string"; + const status = typeof e.httpStatus === "number" ? e.httpStatus : null; + // Require the wire tag — a bare `{ httpStatus }` from unrelated code must not + // be swallowed as a provider outage. + if (!hasUpstreamTag) return null; + if (status === 401 || status === 403) { + return { code: "credential_expired", httpStatus: status }; + } + if (status === 429) return { code: "rate_limited", httpStatus: status }; + return { code: "unavailable", httpStatus: status }; +} + function streamProviderError(args: { code: string }): Response { const stream = createSseStream((controller) => { controller.enqueue( diff --git a/src/lib/ai/__tests__/codex-client.test.ts b/src/lib/ai/__tests__/codex-client.test.ts index a73f06e9d..86ab4fd9e 100644 --- a/src/lib/ai/__tests__/codex-client.test.ts +++ b/src/lib/ai/__tests__/codex-client.test.ts @@ -314,11 +314,15 @@ describe("CodexClient", () => { ); const body = JSON.parse(mockFetch.mock.calls[0][1].body); + // v1.21.3 — the Responses-API function tool carries an explicit `strict` + // field; omitting it is a documented 400 cause on a client-supplied tools + // array (the live Coach tool-call regression). expect(body.tools).toEqual([ { type: "function", name: "fetch_glucose", description: "Fetch glucose readings", + strict: false, parameters: { type: "object", properties: {} }, }, ]); @@ -332,4 +336,84 @@ describe("CodexClient", () => { expect(result.finishReason).toBe("tool_calls"); expect(result.cachedInputTokens).toBe(20); }); + + it("replays a prior assistant turn as output_text, not input_text (Responses API multi-round contract)", async () => { + // v1.21.3 — assistant message replays MUST use `output_text`. Sending + // `input_text` for an assistant turn is a spec violation the Coach tool + // loop hit on round 2+, surfacing as a 400. User turns keep input_text. + const mockFetch = vi + .fn() + .mockResolvedValue( + sseResponse([messageDoneEvent("final"), completedEvent(10)]), + ); + vi.stubGlobal("fetch", mockFetch); + + const client = new CodexClient({ + accessToken: "test-token", + accountId: "acct-test", + onTokenRefresh: vi + .fn() + .mockResolvedValue({ accessToken: "x", accountId: "acct-test" }), + }); + + await client.generateCompletion({ + system: "s", + messages: [ + { role: "user", content: "how is my bp?" }, + { role: "assistant", content: "Let me check." }, + { role: "user", content: "thanks" }, + ], + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const messageItems = body.input.filter( + (i: { type: string }) => i.type === "message", + ); + const userItem = messageItems.find( + (i: { role: string }) => i.role === "user", + ); + const assistantItem = messageItems.find( + (i: { role: string }) => i.role === "assistant", + ); + expect(userItem.content[0].type).toBe("input_text"); + expect(assistantItem.content[0].type).toBe("output_text"); + expect(assistantItem.content[0].text).toBe("Let me check."); + }); + + it("folds the redacted upstream body into the thrown 400 message for diagnosability", async () => { + // v1.21.3 — the 400 body names the rejected field/param. It must reach the + // error message (the chain runner's summariseError reads err.message) so + // the live failure is diagnosable, with bearer/sk- secrets redacted. + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => + 'Bearer secret-token-here {"error":{"message":"Unknown parameter: tools[0].strict"}}', + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new CodexClient({ + accessToken: "test-token", + accountId: "acct-test", + onTokenRefresh: vi + .fn() + .mockResolvedValue({ accessToken: "x", accountId: "acct-test" }), + }); + + let caught: unknown; + try { + await client.generateCompletion( + singleUserTurn({ system: "s", user: "u" }), + ); + } catch (e) { + caught = e; + } + const e = caught as Error & { httpStatus?: number; bodyExcerpt?: string }; + expect(e.httpStatus).toBe(400); + expect(e.message).toContain("Codex request failed (400)"); + expect(e.message).toContain("Unknown parameter"); + // Secret redacted in both the message and the side property. + expect(e.message).not.toContain("secret-token-here"); + expect(e.bodyExcerpt).toContain("Bearer ***redacted***"); + }); }); diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 1283867ed..b58243dff 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -118,8 +118,23 @@ describe("isHardProviderFailure", () => { expect(isHardProviderFailure(err(503))).toBe(true); }); - it("does NOT flag 4xx validation errors (e.g. 422) as hard — those bubble", () => { - expect(isHardProviderFailure(err(422))).toBe(false); + it("flags a raw provider-wire 4xx (e.g. a Codex 400 rejecting the tool-call request) as hard so it cascades and is wrapped rather than bubbling into a 500", () => { + // v1.21.3 — a 4xx surfaced by a provider wire is a provider failure, not a + // caller bug. It must cascade so the chain runner wraps it in + // AllProvidersFailedError; the route then surfaces a graceful provider + // frame instead of rethrowing into an HTTP 500. + expect(isHardProviderFailure(err(400))).toBe(true); + expect(isHardProviderFailure(err(422))).toBe(true); + expect(isHardProviderFailure(err(404))).toBe(true); + }); + + it("does NOT flag an InsightSchemaError as hard — the strict JSON 422 surface still bubbles", async () => { + const { InsightSchemaError } = await import("../schema"); + expect( + isHardProviderFailure( + new InsightSchemaError("bad json", { attempts: 2 }), + ), + ).toBe(false); }); it("flags network errors (no httpStatus) as hard", () => { @@ -445,6 +460,45 @@ describe("runRawCompletionWithFallback — legacy route shim", () => { expect(caught).toBeInstanceOf(AllProvidersFailedError); expect((caught as AllProvidersFailedError).attempts).toHaveLength(2); }); + + it("wraps a sole-provider Codex 400 (tool-call request rejected) in AllProvidersFailedError instead of bubbling the raw error", async () => { + // v1.21.3 — RCA for the live 500. A Codex 400 on the richer tool-call + // request used to escape `isHardProviderFailure` (a non-401/403/429/5xx + // 4xx) and bubble OUT of the chain runner un-wrapped. The Coach route's + // catch only handled AllProvidersFailedError, so the raw error rethrew as + // an HTTP 500 — exactly the incident. The 400 must now cascade and, with no + // healthy provider left, be wrapped so the route can surface a graceful + // provider frame (httpStatus classifies to 503 → coach.provider.unavailable). + const codex = new ScriptedProvider({ + type: "codex", + script: [ + { + ok: false, + error: Object.assign(new Error("Codex request failed (400): {…}"), { + httpStatus: 400, + upstream: "codex", + }), + }, + ], + }); + let caught: unknown; + try { + await runRawCompletionWithFallback({ + userId: "u-raw-codex400", + providers: [{ providerType: "codex", instance: codex }], + params: singleUserTurn({ system: "s", user: "u" }), + }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(AllProvidersFailedError); + const wrapped = caught as AllProvidersFailedError; + expect(wrapped.attempts).toHaveLength(1); + expect(wrapped.attempts[0].httpStatus).toBe(400); + expect(wrapped.primaryCredentialExpired).toBe(false); + // No 5xx / auth in the mix → generic unavailability (503) surface. + expect(wrapped.httpStatus).toBe(503); + }); }); describe("runWithFallback — empty input", () => { diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 4bc717814..42b31c3c6 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -116,15 +116,18 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean { const ORIGINATOR = "healthlog"; const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)"; +/** A Responses-API user/assistant content block. */ +type CodexContentBlock = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string; detail: "high" } + | { type: "output_text"; text: string }; + /** A Responses-API input item. */ type CodexInputItem = | { type: "message"; role: "user" | "assistant"; - content: Array< - | { type: "input_text"; text: string } - | { type: "input_image"; image_url: string; detail: "high" } - >; + content: CodexContentBlock[]; } | { type: "function_call"; @@ -138,20 +141,21 @@ type CodexInputItem = output: string; }; -/** Map an `AiContentPart[]` body into Responses input content blocks. */ +/** + * Map an `AiContentPart[]` body into Responses input content blocks. Text parts + * on an assistant turn use `output_text` (Responses API requires it for + * assistant replays); user turns use `input_text`. Image parts are user-only. + */ function mapCodexParts( parts: AiContentPart[], -): Array< - | { type: "input_text"; text: string } - | { type: "input_image"; image_url: string; detail: "high" } -> { - const out: Array< - | { type: "input_text"; text: string } - | { type: "input_image"; image_url: string; detail: "high" } - > = []; + role: "user" | "assistant", +): CodexContentBlock[] { + const textType: "input_text" | "output_text" = + role === "assistant" ? "output_text" : "input_text"; + const out: CodexContentBlock[] = []; for (const part of parts) { if (part.type === "text") { - out.push({ type: "input_text", text: part.text }); + out.push({ type: textType, text: part.text }); } else if (part.type === "image") { out.push({ type: "input_image", @@ -181,10 +185,18 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] { }); continue; } - const content = + // v1.21.3 — assistant message replays MUST use `output_text`, not + // `input_text` (Responses API; docs/codex-protocol-spec.md §2b). The + // single-user-turn case never hit this — only the multi-round tool loop + // replays a prior assistant turn — so the spec violation surfaced as a 400 + // exactly on the Coach tool-call path that broke in production. User turns + // keep `input_text` / `input_image`. + const textType: "input_text" | "output_text" = + m.role === "assistant" ? "output_text" : "input_text"; + const content: CodexContentBlock[] = typeof m.content === "string" - ? [{ type: "input_text" as const, text: m.content }] - : mapCodexParts(m.content); + ? [{ type: textType, text: m.content }] + : mapCodexParts(m.content, m.role); items.push({ type: "message", role: m.role, content }); if (m.role === "assistant" && m.toolCalls) { for (const tc of m.toolCalls) { @@ -200,12 +212,23 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] { return items; } -/** Map tool defs into the Responses `function` tool shape. */ +/** + * Map tool defs into the Responses `function` tool shape. + * + * v1.21.3 — the Responses-API function tool requires an explicit `strict` + * field; omitting it is one documented cause of a 400 on a client-supplied + * `tools` array. We send `strict: false` because the Coach tool schemas are + * not authored to the strict-mode contract (which additionally demands + * `additionalProperties: false` and every property in `required`). This is + * codex-only — the OpenAI Chat-Completions client nests tools differently and + * is untouched. + */ function buildCodexTools(tools: AiToolDef[]) { return tools.map((t) => ({ type: "function" as const, name: t.name, description: t.description, + strict: false, parameters: t.parameters, })); } @@ -347,7 +370,18 @@ export class CodexClient implements AIProvider { // Non-slug error (5xx, 429, invalid_prompt, etc.) — propagate // immediately. Walking the chain wouldn't help. - const err = new Error(`Codex request failed (${firstAttempt.status})`); + // + // v1.21.3 — fold the redacted upstream body into the message itself, not + // just a side property. Codex's 400 body names the exact field/param it + // rejected; without it in the message the chain runner's `summariseError` + // (which reads `err.message`) logged a bare "Codex request failed (400)" + // with no actionable reason — which is what made the live tool-call 400 + // un-diagnosable. The body is already redacted of bearer/sk- secrets. + const err = new Error( + bodyExcerpt + ? `Codex request failed (${firstAttempt.status}): ${bodyExcerpt}` + : `Codex request failed (${firstAttempt.status})`, + ); Object.assign(err, { httpStatus: firstAttempt.status, upstream: "codex", @@ -391,7 +425,13 @@ export class CodexClient implements AIProvider { ): Promise { const rawBody = await res.text().catch(() => ""); const bodyExcerpt = redactBody(rawBody); - const err = new Error(`${message} (${res.status})`); + // v1.21.3 — fold the redacted body into the message (see the non-slug error + // path); the chain runner's `summariseError` reads `err.message`. + const err = new Error( + bodyExcerpt + ? `${message} (${res.status}): ${bodyExcerpt}` + : `${message} (${res.status})`, + ); Object.assign(err, { httpStatus: res.status, upstream: "codex", diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index cfb8e5795..5344190cd 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -120,12 +120,21 @@ function rememberWorkingProvider( * - Network errors (no `httpStatus`) — DNS, ECONNRESET, timeout. * * No: - * - 4xx other than 401/403/429 — usually validation; the wrapper's - * 422 path already covers schema mismatch and that surface is - * valuable for prompt-debugging. - * - `InsightSchemaError` — same logic; surfaces the wrapper's - * existing 422 so the user sees "model JSON malformed" rather - * than getting silent provider drift. + * - `InsightSchemaError` — surfaces the wrapper's existing 422 so the + * user sees "model JSON malformed" rather than getting silent + * provider drift. This is the ONLY non-hard class. + * + * v1.21.3 — a 4xx that the PROVIDER WIRE returned (tagged `upstream`, + * e.g. a Codex `400` rejecting the tool-call / structured request shape) + * is now hard. It used to fall through `isHardProviderFailure → false`, + * so the raw error bubbled out of the chain runner UNWRAPPED — the Coach + * route's catch only handles `AllProvidersFailedError`, so an un-wrapped + * provider error rethrew as an HTTP 500. Treating every upstream 4xx as + * hard means it cascades to the next provider and, on exhaustion, is + * wrapped in `AllProvidersFailedError` so the route surfaces a graceful + * `coach.provider.*` frame instead of a 500. `InsightSchemaError` + * (no `upstream` tag) is still excluded, so the strict JSON surface keeps + * its 422 "model JSON malformed" diagnostic. */ export function isHardProviderFailure(error: unknown): boolean { if (error instanceof InsightSchemaError) return false; @@ -138,6 +147,10 @@ export function isHardProviderFailure(error: unknown): boolean { } if (status === 401 || status === 403 || status === 429) return true; if (status >= 500) return true; + // Any other 4xx surfaced by a provider wire (the Codex / OpenAI / + // Anthropic clients all tag their thrown errors) is a provider failure, + // not a caller bug — cascade rather than bubble raw into a 500. + if (status >= 400) return true; return false; } @@ -312,14 +325,30 @@ async function resolveChainOrder( return applyLastWorkingCache(userId, afterSkips); } -function summariseError(e: unknown): { reason: string; status: number | null } { - const err = e as { message?: string; httpStatus?: number }; +function summariseError(e: unknown): { + reason: string; + status: number | null; + bodyExcerpt: string | null; +} { + const err = e as { + message?: string; + httpStatus?: number; + bodyExcerpt?: string; + }; const status = typeof err.httpStatus === "number" ? err.httpStatus : null; const message = err.message ?? "unknown error"; + // v1.21.3 — surface the provider's redacted response body (the codex client + // attaches it) so the wide-event carries the upstream's actual rejection + // reason, not just "HTTP 400". Already redacted of secrets at the client. + const bodyExcerpt = + typeof err.bodyExcerpt === "string" && err.bodyExcerpt.length > 0 + ? err.bodyExcerpt.slice(0, 500) + : null; // Cap to keep wide-event payloads bounded. return { reason: status !== null ? `HTTP ${status}: ${message}` : message, status, + bodyExcerpt, }; } @@ -379,6 +408,7 @@ export async function runWithFallback( [`ai_chain_hop_${i + 1}_provider`]: candidate.providerType, [`ai_chain_hop_${i + 1}_status`]: summary.status, [`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240), + [`ai_chain_hop_${i + 1}_body`]: summary.bodyExcerpt, }, }); } @@ -468,6 +498,7 @@ export async function runRawCompletionWithFallback(args: { [`ai_chain_hop_${i + 1}_provider`]: candidate.providerType, [`ai_chain_hop_${i + 1}_status`]: summary.status, [`ai_chain_hop_${i + 1}_reason`]: summary.reason.slice(0, 240), + [`ai_chain_hop_${i + 1}_body`]: summary.bodyExcerpt, }, }); } From 61e21ea28b45e3735a58b3ad6daf8ab920f367af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 10:33:11 +0200 Subject: [PATCH 2/3] fix(coach): show only the animated dots while the Coach is thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-stream thinking bubble dropped the visible "Thinking…" word and now renders just the three bouncing dots. The label stays in an sr-only span so the thinking state is still announced to screen readers. --- .../insights/coach-panel/message-thread.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 54c1880c0..088b83235 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -951,10 +951,9 @@ export function TypingDots({ label }: { label: string }) { return ( {label} @@ -967,12 +966,6 @@ export function TypingDots({ label }: { label: string }) { /> ))} - ); } From 5142582d3db73969ecbd8058737ec2ebd809fa7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 27 Jun 2026 10:33:11 +0200 Subject: [PATCH 3/3] chore(release): v1.21.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hotfix: the Coach and daily briefing failed for ChatGPT-provider accounts — a provider tool-call rejection surfaced as a 500. Provider failures now degrade gracefully, the Codex tool-call wire format is corrected, and the provider error body is captured for diagnosis. Also: the thinking bubble shows only the animated dots. --- CHANGELOG.md | 12 ++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- src/app/api/insights/chat/route.ts | 4 +--- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c620cee7..e065ef2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [1.21.2.1] — 2026-06-27 — Coach + briefing provider hotfix + +A hotfix. No migrations, no breaking changes. + +### Fixed + +- The Coach and the daily briefing failed for accounts signed in through the ChatGPT provider: the provider rejected the multi-step tool-call request, and the failure surfaced as a server error instead of a graceful message. A provider failure now always degrades to a clear in-chat notice — with the correct reconnect or rate-limit prompt where those apply — rather than a 500, and the provider request follows the documented tool-call wire format so the Coach's multi-round reasoning and the briefing generate again. The provider's own error detail is now captured for diagnosis. + +### Changed + +- While the Coach is thinking, the bubble shows only the three animated dots — the "Thinking…" word is gone (it remains for screen readers). + ## [1.21.2] — 2026-06-27 — Ambient Coach presence A patch release. The Coach surfaces what it already computes — at the metric, on the score, in the briefing — instead of waiting to be asked. No migrations, no breaking changes. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f3bf9d7a6..cfa67c713 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.21.2 + version: 1.21.2.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index bf6672d7d..85281c707 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.21.2", + "version": "1.21.2.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 2d7b374cc..484288a22 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.21.2"; + /* @sw-version-fallback */ "v1.21.2.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 5b2f9bdeb..c43ad8afa 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -1107,9 +1107,7 @@ async function streamRefusal(args: { * GlitchTip path. Status mapping mirrors `AllProvidersFailedError`: 401/403 → * credential_expired, 429 → rate_limited, everything else → unavailable. */ -function classifyBubblingProviderError( - err: unknown, -): { +function classifyBubblingProviderError(err: unknown): { code: "credential_expired" | "rate_limited" | "unavailable"; httpStatus: number | null; } | null {