diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 1ea3243..3a2adf9 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -429,7 +429,7 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); - it("releases an accepted submission from an idle snapshot after reconnect", async () => { + it("releases an accepted submission from a post-acceptance idle snapshot", async () => { vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined); const first = new FakeSocket(); const second = new FakeSocket(); @@ -452,14 +452,15 @@ describe("structured connection: reconnection/error/loading", () => { conn.dispose(); }); - it("releases when reconnect idle arrives before submission acceptance", async () => { + it("does not release from reconnect idle cached before submission acceptance", async () => { let accept!: () => void; vi.mocked(sendStructuredPrompt).mockImplementationOnce( () => new Promise((resolve) => { accept = resolve; }), ); const first = new FakeSocket(); const second = new FakeSocket(); - const conn = mount({ sockets: [first, second], shouldReconnect: () => true }); + const third = new FakeSocket(); + const conn = mount({ sockets: [first, second, third], shouldReconnect: () => true }); first.open(); first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); first.emit({ type: "reduced_state", state: idleState }); @@ -477,6 +478,12 @@ describe("structured connection: reconnection/error/loading", () => { accept(); await Promise.resolve().then(() => Promise.resolve()); + expect(input.disabled).toBe(true); + expect(second.closed).toBe(true); + + third.open(); + third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + third.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); conn.dispose(); }); diff --git a/web/src/structured.ts b/web/src/structured.ts index 0425dc6..6b8b021 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -297,11 +297,12 @@ export function createStructuredConnection( renderComposerState(); sendStructuredPrompt(sessionId, text).then( () => { - if (generation === submissionGeneration) { - acceptedSubmission = { generation, socketGeneration: submissionSocketGeneration }; - reconcileAwaitingBegin(); - renderComposerState(); - } + if (generation !== submissionGeneration || awaitingBeginGeneration !== generation) return; + const acceptanceSocketGeneration = socketGeneration; + acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration }; + // A replacement socket may have captured idle before the POST claimed + // the prompt. Reconnect again so its snapshot cannot predate the 204. + if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); }, (e: unknown) => { if (generation !== submissionGeneration) return; @@ -340,6 +341,21 @@ export function createStructuredConnection( let reconnectTimer: number | null = null; let disposed = false; + function reconnectForReconciliation(): void { + const stale = ws; + stale.onopen = null; + stale.onmessage = null; + stale.onerror = null; + stale.onclose = null; + try { + stale.close(); + } catch { + // ignore + } + setConnState("reconnecting…", "connecting"); + connect(); + } + function connect(): void { const connectionGeneration = ++socketGeneration; ws = socketFactory(sessionId);