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.
This commit is contained in:
amosblomqvist
2026-10-27 18:35:31 +02:00
parent 282f01e1f4
commit 5984eb4914
2 changed files with 28 additions and 5 deletions
+11 -4
View File
@@ -715,10 +715,17 @@ export class SessionModel {
throw new Error("A turn is already running — try again in a moment."); throw new Error("A turn is already running — try again in a moment.");
} }
if (!res.ok) { if (!res.ok) {
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body // Keep model/key refusal envelopes verbatim: the page's refusal
// text rides the error message — the banner shows it, and the // flow parses them. Retry's 400s are ordinary user-facing
// page's refusal flow JSON-parses the envelope out of it. // guidance, so show their error text without JSON syntax.
throw new Error((await res.text()) || `The send failed (${res.status}).`); 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 // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze
// postmortem): the POST acknowledges and closes; there is no stream // postmortem): the POST acknowledges and closes; there is no stream
+17 -1
View File
@@ -487,11 +487,27 @@ describe("the Retry button's client path", () => {
}); });
await m.send({ type: "retry" }); await m.send({ type: "retry" });
assert.equal(m.getState().status, "error"); 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.deepEqual(m.getState().nativeMessages, compacted);
assert.equal(endsInFailedTurn(m.getState().nativeMessages), false); 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 () => { it("after a dropped request, Retry posts straight from the error status", async () => {
const failedTurn = [newUserMessage("hello"), failed("a1")]; const failedTurn = [newUserMessage("hello"), failed("a1")];
const posted: string[] = []; const posted: string[] = [];