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) =>