From a688ed4ca2c0c497cc16497c19095c223ab46d1c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:18:30 -0700 Subject: [PATCH] fix(native-chat): sends a Stop outran no longer stall the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a Stop could leave messages stuck on "Sending…": - the resend timer picked the first message in doubt even when it was one the Stop outran (never resent), so a later message in doubt behind it was never resent either; it now resends the message the queue is actually waiting on; - a Stop answered while the agent was idle writes nothing to the journal, so its answer's position was one the chat had already read, and the settling step only ran when the journal moved. It now also runs when the outbox changes, so the outrun send comes back to the composer at once. --- ...-agent-session-outbox-unconfirmed-probe.ts | 8 ++- ...d-agent-session-outbox.stop-stamp.test.tsx | 58 +++++++++++++++++++ .../use-structured-agent-session-outbox.ts | 7 ++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts index 723c8d3ddbe..89cf7834a5a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission' import { structuredAgentSessionEntryResendsUnconfirmed } from '../../../../shared/structured-agent-session-outbox-unconfirmed-resend' import { STRUCTURED_AGENT_SESSION_SEND_UNCONFIRMED_WORDS, @@ -32,9 +33,10 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { // A send with no answer may never have reached the host, and nothing else moves it out of // `unconfirmed`, so one would wedge the whole FIFO queue. The same id again is idempotent: the - // host replays a recorded answer or performs a genuine first delivery. The first `unconfirmed` - // entry is the one holding the queue, at whatever index it sits. - const blocker = outbox.find((entry) => entry.state === 'unconfirmed') + // host replays a recorded answer or performs a genuine first delivery. The entry the queue's + // admission is blocked on is the one resent, never one a Stop outran or an older build left. + const admission = admitStructuredAgentSessionOutboxEntry(outbox) + const blocker = admission.state === 'blocked' ? admission.entry : undefined // The delivery notices read the same rule: while it is resent here, its row says it is sending. // Primitives only: `submissions` is rebuilt on every streaming batch, and an array-identity dep // would reset the backoff forever while the agent is working. diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx index 94852029e92..3c038ecbbeb 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-stamp.test.tsx @@ -227,6 +227,64 @@ describe('a send a Stop outran', () => { }) }) +describe('a Stop answered while the journal is already read through it', () => { + it('an idle Stop answered at the cursor already held settles the send with no new journal batch', async () => { + seedAttempted() + const props: Props = { fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 7 } } + const view = mount(props) + act(() => view.result.current.stop('stop-1')) + // An idle host writes nothing for a Stop, so its answer's cursor is the head already loaded. + act(() => + view.result.current.recordStopAnswer('stop-1', { + kind: 'answered', + cursor: { epoch: 'e', sequence: 7 } + }) + ) + view.rerender(props) + expect(view.result.current.outbox).toEqual([]) + expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up') + expect(view.result.current.error).toBeNull() + expect(mocks.call).not.toHaveBeenCalled() + }) + + it('a refused Stop with the journal already loaded settles the send with no new journal batch', async () => { + seedAttempted() + const props: Props = { fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 7 } } + const view = mount(props) + act(() => view.result.current.stop('stop-1')) + act(() => view.result.current.recordStopAnswer('stop-1', { kind: 'unanswerable' })) + view.rerender(props) + expect(view.result.current.outbox).toEqual([]) + expect(readNativeChatDraftCache(SCOPE)).toBe('follow-up') + expect(view.result.current.error).toBe(CHECK_THE_CHAT) + }) +}) + +describe('a send in doubt behind one a Stop outran', () => { + it('is the one resent: the queue waits on it, not on the stamped one', async () => { + const later = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'later', + sessionId: 'session-1', + text: 'later message', + attachments: [], + queuedAt: 2 + }), + lastAttemptAt: 6, + state: 'unconfirmed' as const + } + seedAttempted({ stoppedBy: { operationId: 'stop-1' } }) + writeOutbox('session-1', [...readOutbox('session-1'), later]) + mount({ fence: 1, submissions: [], journalCursor: { epoch: 'e', sequence: 3 } }) + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000) + }) + const sent = mocks.call.mock.calls.map((call) => call[2].envelope.clientOperationId) + expect(sent).toContain('later') + expect(sent).not.toContain('out') + }) +}) + describe('a message an older build held for a Retry', () => { it.each([ ['outlived by a Stop', { outlivedStop: true, state: 'queued' }], diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 66c40b1b48e..a445695f28b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -137,8 +137,9 @@ export function useStructuredAgentSessionOutbox(args: { const [drains, setDrains] = useState(0) const drainAgain = useCallback(() => setDrains((count) => count + 1), []) - // Everything the journal settles, every time it moves: a row, a published card, the Stop's - // answer read through, or an entry an older build left waiting for a Retry. + // Everything the journal settles, every time it or the outbox moves: a row, a published card, the + // Stop's answer read through, or an entry an older build left. The outbox too, since an idle + // Stop answers at a cursor the journal already reached, and nothing else would move. useEffect(() => { const reading = { submissions, @@ -189,7 +190,7 @@ export function useStructuredAgentSessionOutbox(args: { // Nothing above may have written the outbox, so the drain is told to look again. drainAgain() } - }, [drainAgain, journalCursor, queuedMessageIds, sessionId, submissions]) + }, [drainAgain, journalCursor, outbox, queuedMessageIds, sessionId, submissions]) const journalHasRow = useCallback( (clientMessageId: string) =>