fix: retire stale composer recovery drafts before retry

A failed-response Retry submits no composer text. Retire the earlier accepted text send's recovery fallback synchronously so a Retry model/key refusal cannot restore it over the current unsent draft. Never clear a later ordinary send's fallback at async settlement.

Add key/model503, superseding-draft and ordinary-refusal coverage. Verified 183 focused tests, typecheck, full ESLint, production build, and a red/green live production regression plus ordinary-draft recovery; no external provider calls.
This commit is contained in:
amosblomqvist
2026-10-27 18:56:55 +02:00
parent 5984eb4914
commit 3b29b5e9e7
2 changed files with 75 additions and 2 deletions
+13 -1
View File
@@ -162,6 +162,18 @@ export function parseProviderKeyRefusal(message: string | undefined): boolean {
} }
} }
/** Retry sends no composer text. Its refusal must not resurrect the draft
* from an earlier, already-durable text send over today's unsent composer.
* Retire that fallback synchronously, never when the async Retry settles:
* a later ordinary send may own a new recovery draft by then. */
export function retryFailedResponse(
send: (input: ChatInput) => void,
submittedDraft: { current: string | null },
): void {
submittedDraft.current = null;
void send({ type: "retry" });
}
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -676,7 +688,7 @@ export function Chat({
// "error" too: a dropped stream leaves the model there, and send() // "error" too: a dropped stream leaves the model there, and send()
// recovers from it. Later compaction requires new input instead. // recovers from it. Later compaction requires new input instead.
const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages); const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages);
const retryFailedTurn = () => void send({ type: "retry" }); const retryFailedTurn = () => retryFailedResponse(send, draftRef);
const canSend = const canSend =
(text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting; (text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting;
+62 -1
View File
@@ -1,6 +1,9 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx"; import { parseModelsRefusal, parseProviderKeyRefusal, retryFailedResponse } from "../components/chat.tsx";
import { SessionModel } from "../components/session-core.ts";
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { newUserMessage, type LhMessage } from "../src/core/native-messages.ts";
import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx"; import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx";
import { import {
isModelsRefusal, isModelsRefusal,
@@ -39,6 +42,64 @@ describe("parseModelsRefusal — the send refusal detector", () => {
}); });
}); });
describe("Retry owns no composer recovery draft", () => {
for (const envelope of [providerKeyMissing("openrouter", ["strong"]), modelsNotConfigured(["strong"])]) {
it(`accepted A / failed model / unsent B / Retry ${envelope.code} retires only A's fallback`, async () => {
const durable: LhMessage[] = [
newUserMessage("Accepted text A"),
{ ...fauxAssistantMessage("Partial reply", { stopReason: "error", errorMessage: "Model failed" }), id: "failed-A" },
];
const submittedDraft: { current: string | null } = { current: "Accepted text A" };
let composer = "Unsent text B";
const body = JSON.stringify(envelope);
const model = new SessionModel({
chatId: "fixture", initialMessages: durable,
fetch: async (_url, init) => {
assert.equal(submittedDraft.current, null, "retire A before Retry can reach a refusal");
assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" });
return new Response(body, { status: 503 });
},
});
let request!: Promise<void>;
retryFailedResponse((input) => request = model.send(input), submittedDraft);
await request;
assert.equal(model.getState().error?.message, body, "503 stays raw for existing setup/picker routing");
assert.ok(parseModelsRefusal(body) || parseProviderKeyRefusal(body));
// The existing refusal effect restores only an owned submitted
// draft. Retry must leave this slot empty, not replace B with A.
if (submittedDraft.current !== null) composer = submittedDraft.current;
assert.equal(composer, "Unsent text B");
assert.deepEqual(model.getState().nativeMessages, durable, "Retry adds no optimistic learner message");
});
}
it("does not clear a later ordinary send's recovery draft when Retry settles", async () => {
const submittedDraft: { current: string | null } = { current: "Accepted text A" };
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
retryFailedResponse(async () => pending, submittedDraft);
assert.equal(submittedDraft.current, null);
submittedDraft.current = "Later ordinary text C";
finish();
await pending;
await Promise.resolve();
assert.equal(submittedDraft.current, "Later ordinary text C", "no asynchronous cleanup may retire a newer owner");
});
it("ordinary text refusals still keep their submitted draft for recovery", async () => {
const submittedDraft: { current: string | null } = { current: "Ordinary text C" };
const body = JSON.stringify(providerKeyMissing("openrouter", ["strong"]));
const model = new SessionModel({
chatId: "fixture", initialMessages: [],
fetch: async () => new Response(body, { status: 503 }),
});
await model.send({ type: "text", text: submittedDraft.current! });
assert.equal(submittedDraft.current, "Ordinary text C");
assert.equal(model.getState().error?.message, body);
assert.equal(parseProviderKeyRefusal(model.getState().error?.message), true);
});
});
describe("isModelsRefusal — the job-record matcher", () => { describe("isModelsRefusal — the job-record matcher", () => {
it("matches the runner's composed wording, not real failures", () => { it("matches the runner's composed wording, not real failures", () => {
assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true); assert.equal(isModelsRefusal(`${modelsNotConfigured(["vision"]).error} (missing: vision)`), true);