merge: main (v1.21.2.1 hotfix) into release/v1.21.3

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 10:50:22 +02:00
commit e24e56d436
11 files changed
+337 -43

No files matched your search

+12
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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<typeof vi.fn>).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: {} },
+52
View File
@@ -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,34 @@ 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(
@@ -951,10 +951,9 @@ export function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
// v1.18.7 — show the word alongside the dots (Claude/ChatGPT-like
// "Thinking…") with a gentle pulse, so the brief pre-stream beat
// reads as deliberate rather than a frozen empty bubble. The dots
// bounce in sequence; the label keeps the screen-reader text.
// v1.21.2.1 — the pre-stream beat shows ONLY the three bouncing dots,
// no "Thinking…" word. The label stays in an `sr-only` span so the
// screen-reader still announces the thinking state.
className="text-muted-foreground inline-flex items-center gap-2 py-0.5"
>
<span className="sr-only">{label}</span>
@@ -967,12 +966,6 @@ export function TypingDots({ label }: { label: string }) {
/>
))}
</span>
<span
aria-hidden="true"
className="animate-pulse text-xs motion-reduce:animate-none"
>
{label}
</span>
</span>
);
}
+84
View File
@@ -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***");
});
});
+56 -2
View File
@@ -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", () => {
+60 -20
View File
@@ -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<Error> {
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",
+39 -8
View File
@@ -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,
},
});
}