From 5984eb4914fa3cab7fd7cd148a66578ae1bb36c7 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 27 Oct 2026 18:35:31 +0200 Subject: [PATCH] fix: render failed-response retry refusals as readable text Decode only Retry's HTTP 400 error field for the existing banner. Preserve raw model/key refusal envelopes and other submission-error handling. Verified on the accepted-main integration: 2424 full-suite tests, typecheck, full ESLint, production build and isolated synthetic production-browser Retry/Submit smoke. No real provider calls. --- components/session-core.ts | 15 +++++++++++---- tests/retry-failed-turn.test.ts | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/components/session-core.ts b/components/session-core.ts index c0ef90f..43c7339 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -715,10 +715,17 @@ export class SessionModel { throw new Error("A turn is already running — try again in a moment."); } if (!res.ok) { - // Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body - // text rides the error message — the banner shows it, and the - // page's refusal flow JSON-parses the envelope out of it. - throw new Error((await res.text()) || `The send failed (${res.status}).`); + // Keep model/key refusal envelopes verbatim: the page's refusal + // flow parses them. Retry's 400s are ordinary user-facing + // guidance, so show their error text without JSON syntax. + let message = (await res.text()) || `The send failed (${res.status}).`; + if (input.type === "retry" && res.status === 400) { + try { + const refusal = JSON.parse(message) as { error?: unknown } | null; + if (typeof refusal?.error === "string" && refusal.error.length > 0) message = refusal.error; + } catch { /* Non-envelope failures keep their original text. */ } + } + throw new Error(message); } // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze // postmortem): the POST acknowledges and closes; there is no stream diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts index 38ba054..cdc364b 100644 --- a/tests/retry-failed-turn.test.ts +++ b/tests/retry-failed-turn.test.ts @@ -487,11 +487,27 @@ describe("the Retry button's client path", () => { }); await m.send({ type: "retry" }); assert.equal(m.getState().status, "error"); - assert.equal(JSON.parse(m.getState().error!.message).error, refused, "the existing refusal-envelope path carries both messages"); + assert.equal(m.getState().error!.message, refused, "the banner keeps both messages as readable text, without JSON syntax"); assert.deepEqual(m.getState().nativeMessages, compacted); assert.equal(endsInFailedTurn(m.getState().nativeMessages), false); }); + for (const { status, body, expected } of [ + { status: 400, body: '{"error":"retry input requires a failed turn to retry"}', expected: "retry input requires a failed turn to retry" }, + { status: 400, body: "Plain refusal", expected: "Plain refusal" }, + { status: 400, body: '{"reason":"not an error envelope"}', expected: '{"reason":"not an error envelope"}' }, + { status: 503, body: '{"error":"model gate","code":"models_not_configured"}', expected: '{"error":"model gate","code":"models_not_configured"}' }, + ]) { + it(`preserves the appropriate Retry refusal format for ${status}: ${body}`, async () => { + const m = new SessionModel({ + chatId: "chat_x", initialMessages: [newUserMessage("hello"), failed("failed-response")], + fetch: async () => new Response(body, { status }), + }); + await m.send({ type: "retry" }); + assert.equal(m.getState().error?.message, expected); + }); + } + it("after a dropped request, Retry posts straight from the error status", async () => { const failedTurn = [newUserMessage("hello"), failed("a1")]; const posted: string[] = [];