From 36d143ced00af16d28ea20d65ad66622cb12c09d Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 30 Aug 2026 20:42:55 +1000 Subject: [PATCH 1/5] fix(web): recover stale response streams --- frontend/src/domain/response.test.ts | 17 +++ frontend/src/domain/response.ts | 3 +- frontend/src/stores/app-store.test.ts | 178 +++++++++++++++++++++++ frontend/src/stores/app-store.ts | 2 + frontend/src/stores/run-engine.ts | 50 +++++++ frontend/src/stores/status-reconciler.ts | 16 +- 6 files changed, 263 insertions(+), 3 deletions(-) diff --git a/frontend/src/domain/response.test.ts b/frontend/src/domain/response.test.ts index 0d925fa0..5897fa39 100644 --- a/frontend/src/domain/response.test.ts +++ b/frontend/src/domain/response.test.ts @@ -52,6 +52,23 @@ describe('response projection', () => { expect(new Set(RESPONSE_EVENT_TYPES).size).toBe(RESPONSE_EVENT_TYPES.length); }); + it('adopts the server epoch from the initial response.created event', () => { + const projection = reduceResponse( + initialProjection(run), + event('response.created', 1, { run_epoch: 1788084563000000 }), + ); + + expect(projection.run).toMatchObject({ + responseId: 'r1', + epoch: 1788084563000000, + lastSequence: 1, + status: 'streaming', + }); + expect(() => + reduceResponse(projection, event('response.output_text.delta', 2, { run_epoch: 1 })), + ).toThrow(ResponseProtocolError); + }); + it('does not project the compaction resume handoff below its transcript boundary', () => { let projection = initialProjection(run); projection = reduceResponse( diff --git a/frontend/src/domain/response.ts b/frontend/src/domain/response.ts index 765843ea..0af1ca01 100644 --- a/frontend/src/domain/response.ts +++ b/frontend/src/domain/response.ts @@ -254,7 +254,8 @@ function validate( if (responseId !== projection.run.responseId) throw new ResponseProtocolError(`Response owner mismatch: ${responseId}`, 'owner'); const epoch = number(event.run_epoch); - if (!epoch || epoch !== projection.run.epoch) + const adoptingCreatedEpoch = event.type === 'response.created' && projection.run.lastSequence === 0; + if (!epoch || (!adoptingCreatedEpoch && epoch !== projection.run.epoch)) throw new ResponseProtocolError(`Response epoch mismatch: ${epoch}`, 'epoch'); const sequence = number(event.sequence_number); if (!sequence) diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 19f39c33..652d7201 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -2033,6 +2033,71 @@ describe('AppStore compatibility behavior', () => { } }); + it('keeps the admitted POST stream when response.created supplies the server epoch', async () => { + const store = new AppStore(config); + let postSignal: AbortSignal | undefined; + try { + store.sessions.value = [session()]; + store.activeSessionId.value = 's1'; + store.draftActive.value = false; + store.prompt.value = 'Use the original response stream'; + store.endpoints.response = vi.fn(async () => { + throw new Error('snapshot recovery should not run'); + }); + store.endpoints.createResponse = vi.fn(async (_body, _sessionId, _requestId, signal) => { + postSignal = signal; + const encoder = new TextEncoder(); + const frames = [ + ['response.created', { response: { id: 'r1', status: 'in_progress' } }], + ['response.output_text.delta', { delta: 'Done.' }], + ['response.completed', { response: { id: 'r1', status: 'completed' }, final_rev: 2 }], + ] as const; + const body = new ReadableStream({ + start(controller) { + frames.forEach(([type, payload], index) => + controller.enqueue( + encoder.encode( + `event: ${type}\ndata: ${JSON.stringify({ + ...payload, + response_id: 'r1', + run_epoch: 1788084563000000, + sequence_number: index + 1, + })}\n\n`, + ), + ), + ); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + return new Response(body, { + headers: { 'x-response-id': 'r1', 'x-session-id': 's1' }, + }); + }); + store.endpoints.selectedSession = vi.fn(async () => ({ + selected_session: { id: 's1', transcript_rev: 2 }, + selected_transcript: { bodies: { rev: 2, messages: [] } }, + })); + store.endpoints.sessionState = vi.fn(async () => ({})); + + await store.send(); + + expect(postSignal?.aborted).toBe(true); + expect(store.endpoints.response).not.toHaveBeenCalled(); + expect(store.runs.value.s1.run).toMatchObject({ + responseId: 'r1', + epoch: 1788084563000000, + status: 'completed', + lastSequence: 3, + }); + expect(store.runs.value.s1.messages).toEqual([ + expect.objectContaining({ role: 'assistant', content: 'Done.' }), + ]); + } finally { + store.dispose(); + } + }); + it('ignores late transport failures after a response is already complete', async () => { const store = new AppStore(config); store.sessions.value = [session()]; @@ -2618,6 +2683,61 @@ describe('AppStore compatibility behavior', () => { } }); + it('retries when a replacement response stream never returns headers', async () => { + vi.useFakeTimers(); + const store = new AppStore(config); + let connectSignal: AbortSignal | undefined; + try { + store.sessions.value = [{ ...session(), activeRun: true, activeResponseId: 'r1' }]; + store.activeSessionId.value = 's1'; + store.runs.value = { + s1: initialProjection({ + responseId: 'r1', + sessionId: 's1', + epoch: 1, + status: 'connecting', + lastSequence: 3, + startedRev: 0, + reconnects: 0, + }), + }; + store.endpoints.responseEvents = vi.fn((_responseId, _after, signal) => { + connectSignal = signal; + // Model WebKit leaving fetch pending even after its signal is aborted. + return new Promise(() => undefined); + }); + store.endpoints.response = vi.fn(async () => ({ + id: 'r1', + status: 'completed', + run_epoch: 1, + last_sequence_number: 4, + final_rev: 1, + recovery: { sequence_number: 4 }, + })); + store.endpoints.selectedSession = vi.fn(async () => ({ + selected_session: { id: 's1' }, + selected_transcript: { bodies: { rev: 1, messages: [] } }, + })); + store.endpoints.sessionState = vi.fn(async () => ({})); + + void store.streamResponse('r1', 's1', 3); + await Promise.resolve(); + expect(connectSignal?.aborted).toBe(false); + + await vi.advanceTimersByTimeAsync(15_000); + + expect(connectSignal?.aborted).toBe(true); + expect(store.runs.value.s1.run).toMatchObject({ status: 'connecting', reconnects: 1 }); + + await vi.advanceTimersByTimeAsync(1_500); + await vi.waitFor(() => expect(store.endpoints.response).toHaveBeenCalledOnce()); + expect(store.runEngine.currentSupervisor('s1')).toBeUndefined(); + } finally { + store.dispose(); + vi.useRealTimers(); + } + }); + it('recovers an active run when no response transport is owned', async () => { const store = new AppStore(config); try { @@ -2691,6 +2811,64 @@ describe('AppStore compatibility behavior', () => { expect(internals.resumeResponse).toHaveBeenCalledWith('s1', 'finished-while-suspended'); }); + it('retires an expired response after authoritative idle transcript reconciliation', async () => { + const store = new AppStore(config); + try { + store.sessions.value = [ + { + ...session(), + activeRun: true, + activeResponseId: 'expired-response', + transcriptRev: 1, + messageBodiesRev: 1, + }, + ]; + store.activeSessionId.value = 's1'; + store.runs.value = { + s1: initialProjection({ + responseId: 'expired-response', + sessionId: 's1', + epoch: 99, + status: 'streaming', + lastSequence: 25, + startedRev: 1, + reconnects: 0, + }), + }; + store.endpoints.sessionStatus = vi.fn(async () => ({ + sessions: [{ id: 's1', transcript_rev: 2 }], + __etag: 'idle-etag', + })); + store.endpoints.response = vi.fn(async () => { + throw new APIError('response not found', 404); + }); + store.endpoints.selectedSession = vi.fn(async () => ({ + selected_session: { id: 's1', transcript_rev: 2 }, + selected_transcript: { bodies: { rev: 2, messages: [] } }, + })); + store.endpoints.sessionState = vi.fn(async () => ({})); + const internals = store as unknown as { + refreshStatus(authoritative?: boolean): Promise; + }; + + await internals.refreshStatus(true); + + await vi.waitFor(() => expect(store.runs.value.s1).toBeUndefined()); + expect(store.endpoints.sessionStatus).toHaveBeenCalledWith('s1', false, ['all'], ''); + expect(store.sessions.value[0]).toMatchObject({ + activeRun: false, + activeResponseId: null, + messageBodiesRev: 2, + lastResponseId: 'expired-response', + }); + expect(store.runEngine.currentSupervisor('s1')).toBeUndefined(); + expect(store.runActive.value).toBe(false); + expect(store.streaming.value).toBe(false); + } finally { + store.dispose(); + } + }); + it('does not probe a provisional response before the server admits it', async () => { const store = new AppStore(config); store.sessions.value = [{ ...session(), activeRun: true }]; diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index 9ade327c..8e9d3c61 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -480,6 +480,8 @@ export class AppStore { reconcile: (reason, authoritative) => this.reconcile(reason, { authoritative }), refreshSidebar: (authoritative) => this.refreshSidebar(authoritative), resumeResponse: (sessionId, responseId) => this.resumeResponse(sessionId, responseId), + reconcileServerIdleResponse: (sessionId, responseId, transcriptRev) => + this.runEngine.reconcileServerIdleResponse(sessionId, responseId, transcriptRev), refreshSessionMessages: (sessionId, targetRev) => this.refreshSessionMessages(sessionId, targetRev), syncSessionMessagesForAttach: (sessionId, targetRev) => diff --git a/frontend/src/stores/run-engine.ts b/frontend/src/stores/run-engine.ts index cfa8b5fa..69c12a60 100644 --- a/frontend/src/stores/run-engine.ts +++ b/frontend/src/stores/run-engine.ts @@ -43,6 +43,7 @@ import type { TabEventType } from '../platform/tab-sync'; // Supervisor backoff guarantees seven consecutive failures represent more than // thirty seconds without a successfully attached response transport. const STALE_RESPONSE_RECOVERY_FAILURES = 7; +const RESPONSE_STREAM_CONNECT_TIMEOUT_MS = 15_000; export interface RunEngineHost { loadSession: (id: string, epoch?: number) => Promise; @@ -812,6 +813,16 @@ export class RunEngine { this.supervisors.finishSubscription(owner, transportGeneration); return; } + this.supervisors.touchWatchdog( + owner, + transportGeneration, + () => { + this.services.bumpDiagnostic('streamWatchdogTimeouts'); + abort.abort(new DOMException('Response stream connection timed out', 'TimeoutError')); + this.scheduleSupervisorRetry(owner, new Error('Response stream connection timed out')); + }, + RESPONSE_STREAM_CONNECT_TIMEOUT_MS, + ); try { const response = await this.services.endpoints.responseEvents( owner.responseId, @@ -1220,6 +1231,45 @@ export class RunEngine { await this.recoverSupervisor(owner); } + async reconcileServerIdleResponse( + sessionId: string, + responseId: string, + transcriptRev: number, + ): Promise { + try { + await this.host.refreshSessionMessages(sessionId, transcriptRev); + } catch { + return; + } + const session = this.sessionStore.sessions.peek().find((entry) => entry.id === sessionId); + const projection = this.runs.peek()[sessionId]; + if ( + !session || + session.activeRun || + session.activeResponseId || + (transcriptRev > 0 && (session.messageBodiesRev || 0) < transcriptRev) || + projection?.run.responseId !== responseId || + !['connecting', 'checking', 'streaming', 'cancelling'].includes(projection.run.status) + ) + return; + + this.retiredResponses.add(responseId); + this.clearResponseTransport(sessionId, responseId); + const owner = this.supervisors.current(sessionId); + if (owner?.responseId === responseId) this.supervisors.retire(owner); + const nextRuns = { ...this.runs.peek() }; + delete nextRuns[sessionId]; + batch(() => { + this.runs.value = nextRuns; + this.sessionStore.patch(sessionId, { + activeRun: false, + activeResponseId: null, + lastResponseId: responseId, + }); + }); + this.retireIntent(sessionId); + } + private async waitForSubscriptionIdle(owner: StreamSupervisor): Promise { const deadline = Date.now() + 1_000; while (this.supervisors.owns(owner) && owner.subscriptionInFlight) { diff --git a/frontend/src/stores/status-reconciler.ts b/frontend/src/stores/status-reconciler.ts index 28a230a9..0e9321d3 100644 --- a/frontend/src/stores/status-reconciler.ts +++ b/frontend/src/stores/status-reconciler.ts @@ -37,6 +37,11 @@ export interface StatusReconcilerHost { reconcile: (reason: string, authoritative: boolean) => Promise; refreshSidebar: (authoritative?: boolean) => Promise; resumeResponse: (sessionId: string, responseId: string) => Promise; + reconcileServerIdleResponse: ( + sessionId: string, + responseId: string, + transcriptRev: number, + ) => Promise; refreshSessionMessages: (sessionId: string, targetRev?: number) => Promise; syncSessionMessagesForAttach: (sessionId: string, targetRev?: number) => Promise; refreshDiffComments: (sessionId: string) => Promise; @@ -132,7 +137,7 @@ export class StatusReconciler { metadata.selectedSessionId, metadata.showHidden, metadata.categories, - this.coordinator.etag, + authoritative ? '' : this.coordinator.etag, ); const receivedAt = Date.now(); if (!this.statusRequestIsCurrent(metadata)) { @@ -299,7 +304,14 @@ export class StatusReconciler { // can suspend or lose the terminal stream event, so reconcile that // snapshot whenever the two views disagree. A run admitted after // this status request started cannot be disproven by its stale body. - followUps.push(() => void this.host.resumeResponse(session.id, projectedRun.responseId)); + followUps.push(() => { + void this.host.resumeResponse(session.id, projectedRun.responseId); + void this.host.reconcileServerIdleResponse( + session.id, + projectedRun.responseId, + serverTranscriptRev, + ); + }); } if ( !serverActiveResponseId && From 2dd5894d380f6a61322193a7be82e75221fe0e52 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 30 Aug 2026 20:51:49 +1000 Subject: [PATCH 2/5] test(web): cover streaming reconnect takeover --- frontend/src/domain/response.ts | 5 +++- frontend/src/stores/app-store.test.ts | 36 +++++++++++++++++++----- frontend/src/stores/run-engine.ts | 1 + frontend/src/stores/status-reconciler.ts | 2 ++ 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/frontend/src/domain/response.ts b/frontend/src/domain/response.ts index 0af1ca01..e2b17861 100644 --- a/frontend/src/domain/response.ts +++ b/frontend/src/domain/response.ts @@ -254,7 +254,10 @@ function validate( if (responseId !== projection.run.responseId) throw new ResponseProtocolError(`Response owner mismatch: ${responseId}`, 'owner'); const epoch = number(event.run_epoch); - const adoptingCreatedEpoch = event.type === 'response.created' && projection.run.lastSequence === 0; + // Before sequence 1 there is no projected server state to contradict; the + // response.created event establishes the authoritative epoch for this run. + const adoptingCreatedEpoch = + event.type === 'response.created' && projection.run.lastSequence === 0; if (!epoch || (!adoptingCreatedEpoch && epoch !== projection.run.epoch)) throw new ResponseProtocolError(`Response epoch mismatch: ${epoch}`, 'epoch'); const sequence = number(event.sequence_number); diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 652d7201..bf7a3cf3 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -2701,17 +2701,37 @@ describe('AppStore compatibility behavior', () => { reconnects: 0, }), }; - store.endpoints.responseEvents = vi.fn((_responseId, _after, signal) => { - connectSignal = signal; - // Model WebKit leaving fetch pending even after its signal is aborted. - return new Promise(() => undefined); - }); + store.endpoints.responseEvents = vi + .fn() + .mockImplementationOnce((_responseId, _after, signal) => { + connectSignal = signal; + // Model WebKit leaving fetch pending even after its signal is aborted. + return new Promise(() => undefined); + }) + .mockImplementationOnce(async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + `event: response.completed\ndata: ${JSON.stringify({ + response_id: 'r1', + run_epoch: 1, + sequence_number: 5, + response: { id: 'r1', status: 'completed' }, + final_rev: 1, + })}\n\ndata: [DONE]\n\n`, + ), + ); + controller.close(); + }, + }); + return new Response(body); + }); store.endpoints.response = vi.fn(async () => ({ id: 'r1', - status: 'completed', + status: 'in_progress', run_epoch: 1, last_sequence_number: 4, - final_rev: 1, recovery: { sequence_number: 4 }, })); store.endpoints.selectedSession = vi.fn(async () => ({ @@ -2731,6 +2751,8 @@ describe('AppStore compatibility behavior', () => { await vi.advanceTimersByTimeAsync(1_500); await vi.waitFor(() => expect(store.endpoints.response).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(store.endpoints.responseEvents).toHaveBeenCalledTimes(2)); + expect(store.endpoints.responseEvents).toHaveBeenLastCalledWith('r1', 4, expect.any(AbortSignal)); expect(store.runEngine.currentSupervisor('s1')).toBeUndefined(); } finally { store.dispose(); diff --git a/frontend/src/stores/run-engine.ts b/frontend/src/stores/run-engine.ts index 69c12a60..e35e78a0 100644 --- a/frontend/src/stores/run-engine.ts +++ b/frontend/src/stores/run-engine.ts @@ -1236,6 +1236,7 @@ export class RunEngine { responseId: string, transcriptRev: number, ): Promise { + if (transcriptRev <= 0 || this.retiredResponses.has(responseId)) return; try { await this.host.refreshSessionMessages(sessionId, transcriptRev); } catch { diff --git a/frontend/src/stores/status-reconciler.ts b/frontend/src/stores/status-reconciler.ts index 0e9321d3..99a836d0 100644 --- a/frontend/src/stores/status-reconciler.ts +++ b/frontend/src/stores/status-reconciler.ts @@ -305,6 +305,8 @@ export class StatusReconciler { // snapshot whenever the two views disagree. A run admitted after // this status request started cannot be disproven by its stale body. followUps.push(() => { + // Probe the rich terminal snapshot and durable transcript in parallel: + // either transport can be the one iOS left permanently wedged. void this.host.resumeResponse(session.id, projectedRun.responseId); void this.host.reconcileServerIdleResponse( session.id, From 7b8fb6f722b26f63140927d951eb5e7361cc1472 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 30 Aug 2026 20:57:57 +1000 Subject: [PATCH 3/5] fix(ci): refresh frontend bundle budgets Format the reviewed reconnect test and update stale bundle ceilings. Upstream main is already 436 bytes over the 423,000-byte JS raw budget and 161 bytes over the 161,500-byte CSS raw budget; this PR builds at 424,638/124,876 bytes for JS and 161,661/31,132 bytes for CSS under local Go 1.27 compression. Set bounded ceilings of 426,000/126,000 and 162,500/32,000 respectively. --- frontend/src/stores/app-store.test.ts | 6 +++++- internal/serveui/embed_test.go | 11 ++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index bf7a3cf3..ac1d27bb 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -2752,7 +2752,11 @@ describe('AppStore compatibility behavior', () => { await vi.advanceTimersByTimeAsync(1_500); await vi.waitFor(() => expect(store.endpoints.response).toHaveBeenCalledOnce()); await vi.waitFor(() => expect(store.endpoints.responseEvents).toHaveBeenCalledTimes(2)); - expect(store.endpoints.responseEvents).toHaveBeenLastCalledWith('r1', 4, expect.any(AbortSignal)); + expect(store.endpoints.responseEvents).toHaveBeenLastCalledWith( + 'r1', + 4, + expect.any(AbortSignal), + ); expect(store.runEngine.currentSupervisor('s1')).toBeUndefined(); } finally { store.dispose(); diff --git a/internal/serveui/embed_test.go b/internal/serveui/embed_test.go index 9257914c..83f57fb4 100644 --- a/internal/serveui/embed_test.go +++ b/internal/serveui/embed_test.go @@ -34,11 +34,12 @@ func TestProductionBundleSizeBudgets(t *testing.T) { // safe-point branch workflow, interactive worktree conflict recovery, // replayable SSE/long-poll server-event coordination, branch-tree // navigation, the paginated Recent/Projects sidebar, elastic streaming - // presentation buffer, and explicit response authority/transport state are - // first-party shell code. Keep bounded headroom over that productized - // baseline while still failing meaningful accidental regressions. - "dist/app.js": {raw: 423_000, gzip: 124_000}, - "dist/app.css": {raw: 161_500, gzip: 31_000}, + // presentation buffer, explicit response authority/transport state, and + // authoritative mobile stream recovery are first-party shell code. Keep + // bounded headroom over that productized baseline while still failing + // meaningful accidental regressions. + "dist/app.js": {raw: 426_000, gzip: 126_000}, + "dist/app.css": {raw: 162_500, gzip: 32_000}, } for name, budget := range budgets { body, err := StaticAsset(name) From a3aea7d67da92ba43f813f442605c50b43c8061f Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 30 Aug 2026 21:26:29 +1000 Subject: [PATCH 4/5] fix(web): stop interaction reconciliation loops Only publish newly discovered prompts instead of every recovery refresh, and publish terminal state transitions so peer tabs reconcile once. This prevents cross-tab interaction refreshes from bouncing indefinitely and re-rendering approval or ask-user dialogs during user input. --- frontend/src/stores/interaction-store.test.ts | 26 +++++++++++++++++++ frontend/src/stores/interaction-store.ts | 8 ++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/frontend/src/stores/interaction-store.test.ts b/frontend/src/stores/interaction-store.test.ts index aa01be6e..c843f978 100644 --- a/frontend/src/stores/interaction-store.test.ts +++ b/frontend/src/stores/interaction-store.test.ts @@ -1,6 +1,9 @@ +import { signal } from '@preact/signals'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { AskUserPrompt } from '../domain/types'; import { AppStore } from './app-store'; +import { InteractionStore } from './interaction-store'; +import type { Modal } from './store-types'; import { testConfig } from './store-test-fixtures'; beforeEach(() => localStorage.clear()); @@ -36,6 +39,29 @@ describe('InteractionStore', () => { } }); + it('publishes only authoritative interaction transitions, not recovery refreshes', () => { + const app = new AppStore(testConfig); + const publish = vi.fn(); + const interactions = new InteractionStore(app.services, signal(''), publish); + const prompt: AskUserPrompt = { + sessionId: 's1', + callId: 'ask-1', + questions: [{ header: 'Choice', question: 'Continue?', options: [] }], + }; + try { + interactions.upsert('ask-user', 's1', 'r1', 'ask-1', prompt); + interactions.upsert('ask-user', 's1', 'r1', 'ask-1', { ...prompt }); + expect(publish).toHaveBeenCalledTimes(1); + + interactions.resolve('ask-user', 's1', 'r1', 'ask-1', 'answered', 10); + interactions.resolve('ask-user', 's1', 'r1', 'ask-1', 'answered', 20); + expect(publish).toHaveBeenCalledTimes(2); + expect(interactions.shouldOpen('ask-user', 's1', 'ask-1')).toBe(false); + } finally { + app.dispose(); + } + }); + it('deduplicates concurrent submissions inside the interaction owner', async () => { const store = new AppStore(testConfig); try { diff --git a/frontend/src/stores/interaction-store.ts b/frontend/src/stores/interaction-store.ts index 949a77a6..003ce5cb 100644 --- a/frontend/src/stores/interaction-store.ts +++ b/frontend/src/stores/interaction-store.ts @@ -49,8 +49,10 @@ export class InteractionStore { ...this.interactions.peek(), [key]: { ...record, prompt }, }; - if (!existing) this.order.value = [...this.order.peek(), key]; - this.publish('interaction-changed', sessionId, responseId); + if (!existing) { + this.order.value = [...this.order.peek(), key]; + this.publish('interaction-changed', sessionId, responseId); + } return key; } @@ -80,6 +82,7 @@ export class InteractionStore { (kind === 'approval' ? ({ sessionId, id: requestId, title: 'Access request' } satisfies ApprovalPrompt) : ({ sessionId, callId: requestId, questions: [] } satisfies AskUserPrompt)); + const changed = !existing || existing.state !== state || existing.outcome !== outcome; this.interactions.value = { ...this.interactions.peek(), [key]: { @@ -101,6 +104,7 @@ export class InteractionStore { this.services.bumpDiagnostic('interactionReconciliations'); if (kind === 'approval' && this.approval.peek()?.id === requestId) this.approval.value = null; if (kind === 'ask-user' && this.askUser.peek()?.callId === requestId) this.askUser.value = null; + if (changed) this.publish('interaction-changed', sessionId, responseId); } find( From 7e92472e1b53229fafd7586dd49b5ed0409b505b Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 30 Aug 2026 21:32:33 +1000 Subject: [PATCH 5/5] fix(web): reconcile interactions across response IDs Treat a session's request ID as the stable interaction identity when recovery reports a different or newly-known response ID. This keeps a dismissed or resolved prompt from surviving as a duplicate waiting record and reopening its modal. --- frontend/src/stores/interaction-store.test.ts | 6 ++++-- frontend/src/stores/interaction-store.ts | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/stores/interaction-store.test.ts b/frontend/src/stores/interaction-store.test.ts index c843f978..ba2cec36 100644 --- a/frontend/src/stores/interaction-store.test.ts +++ b/frontend/src/stores/interaction-store.test.ts @@ -49,13 +49,15 @@ describe('InteractionStore', () => { questions: [{ header: 'Choice', question: 'Continue?', options: [] }], }; try { - interactions.upsert('ask-user', 's1', 'r1', 'ask-1', prompt); + interactions.upsert('ask-user', 's1', '', 'ask-1', prompt); interactions.upsert('ask-user', 's1', 'r1', 'ask-1', { ...prompt }); expect(publish).toHaveBeenCalledTimes(1); + expect(interactions.order.value).toHaveLength(1); - interactions.resolve('ask-user', 's1', 'r1', 'ask-1', 'answered', 10); + interactions.resolve('ask-user', 's1', 'r2', 'ask-1', 'answered', 10); interactions.resolve('ask-user', 's1', 'r1', 'ask-1', 'answered', 20); expect(publish).toHaveBeenCalledTimes(2); + expect(interactions.order.value).toHaveLength(1); expect(interactions.shouldOpen('ask-user', 's1', 'ask-1')).toBe(false); } finally { app.dispose(); diff --git a/frontend/src/stores/interaction-store.ts b/frontend/src/stores/interaction-store.ts index 003ce5cb..99f4afd3 100644 --- a/frontend/src/stores/interaction-store.ts +++ b/frontend/src/stores/interaction-store.ts @@ -32,8 +32,10 @@ export class InteractionStore { requestId: string, prompt: ApprovalPrompt | AskUserPrompt, ): string { - const key = `${sessionId}:${responseId}:${requestId}`; - const existing = this.interactions.peek()[key]; + const discoveredKey = `${sessionId}:${responseId}:${requestId}`; + const existing = + this.find(kind, sessionId, requestId, responseId) || this.find(kind, sessionId, requestId); + const key = existing?.key || discoveredKey; const record: InteractionRecord = existing || { key, sessionId, @@ -47,7 +49,7 @@ export class InteractionStore { }; this.interactions.value = { ...this.interactions.peek(), - [key]: { ...record, prompt }, + [key]: { ...record, responseId: record.responseId || responseId, prompt }, }; if (!existing) { this.order.value = [...this.order.peek(), key]; @@ -64,7 +66,8 @@ export class InteractionStore { outcome: string, resolvedAt = Date.now(), ): void { - const existing = this.find(kind, sessionId, requestId, responseId); + const existing = + this.find(kind, sessionId, requestId, responseId) || this.find(kind, sessionId, requestId); const key = existing?.key || `${sessionId}:${responseId}:${requestId}`; const normalized = outcome.replaceAll('_', '-'); const state: InteractionRecord['state'] =