From fd8ea0ecbd6133d25179ea6d7e6612ae4434ea75 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 11 Sep 2026 16:50:38 -0700 Subject: [PATCH] fix(native-chat): rotate immediately rejected retries --- ...e-structured-agent-session-outbox.test.tsx | 4 ++-- .../use-structured-agent-session-outbox.ts | 10 +++++++++- ...ructured-agent-session-send-disposition.ts | 20 +++++++++++++------ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 9fc43825455..84ccc0c098f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -643,7 +643,7 @@ describe('useStructuredAgentSessionOutbox', () => { 11 ) ) - const { result, rerender } = renderHook( + const { result } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ sessionId: 'session-1', @@ -669,7 +669,7 @@ describe('useStructuredAgentSessionOutbox', () => { expect(result.current.outbox[0]?.state).toBe('queued') expect(result.current.blockedClientMessageId).toBe(firstId) - rerender({ submissions: [writeFailed(firstId)] }) + // Retry immediately, before the journal subscription can publish the rejected row. act(() => result.current.retry(firstId)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) 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 37d05242c31..e98e1d9985c 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 @@ -52,6 +52,7 @@ export function useStructuredAgentSessionOutbox(args: { const dispatchingRef = useRef(false) const dispatchGenerationRef = useRef(0) const blockedIdRef = useRef(null) + const retryWithFreshClientMessageIdRef = useRef(null) const probeAttemptsRef = useRef({ id: null as string | null, attempts: 0 }) const [error, setError] = useState(null) const [errorSession, setErrorSession] = useState(sessionId) @@ -70,6 +71,7 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 dispatchingRef.current = false blockedIdRef.current = null + retryWithFreshClientMessageIdRef.current = null probeAttemptsRef.current = { id: null, attempts: 0 } }, [fence, sessionId, targetKey]) @@ -125,6 +127,7 @@ export function useStructuredAgentSessionOutbox(args: { const applyDisposition = useCallback( (disposition: StructuredAgentSessionSendDisposition): void => { blockedIdRef.current = disposition.blockedClientMessageId + retryWithFreshClientMessageIdRef.current = disposition.retryWithFreshClientMessageId setError(disposition.error) outboxRef.current = disposition.entries setOutbox(disposition.entries) @@ -275,7 +278,12 @@ export function useStructuredAgentSessionOutbox(args: { // A provider-history reconciliation can settle an earlier unknown as // rejected before the user presses Retry. Reusing that operation id only // replays the settled rejection forever, so rotate the id for a safe resend. - if (current && submission?.dispatchState === 'rejected') { + if ( + current && + (submission?.dispatchState === 'rejected' || + retryWithFreshClientMessageIdRef.current === clientMessageId) + ) { + retryWithFreshClientMessageIdRef.current = null const rotated = outboxRef.current.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 041ec56f0c5..135ebc42353 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -24,6 +24,8 @@ export type StructuredAgentSessionSendDisposition = { /** The entry the queue is stuck on, or null when nothing blocks it. Always the * next value, never "unchanged": the caller assigns it verbatim. */ blockedClientMessageId: string | null + /** A rejected result arrived before the journal snapshot; Retry must rotate this id. */ + retryWithFreshClientMessageId: string | null } type SendDispositionInput = { @@ -118,7 +120,8 @@ export function disposeStructuredAgentSessionSendResult( return { entries, error: result.refusal.message, - blockedClientMessageId: entries[0]?.clientMessageId ?? null + blockedClientMessageId: entries[0]?.clientMessageId ?? null, + retryWithFreshClientMessageId: null } } const submission = result.value.submission @@ -126,21 +129,24 @@ export function disposeStructuredAgentSessionSendResult( return { entries: dropEntry(input), error: 'Message delivery is unconfirmed and Orca will not send it again', - blockedClientMessageId: input.blockedClientMessageId + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null } } if (submission.dispatchState === 'accepted') { return { entries: dropEntry(input), error: null, - blockedClientMessageId: input.blockedClientMessageId + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null } } if (submission.dispatchState === 'rejected') { return { entries: replaceEntryState(input, 'queued'), error: rejectionNotice(submission.reason), - blockedClientMessageId: input.entry.clientMessageId + blockedClientMessageId: input.entry.clientMessageId, + retryWithFreshClientMessageId: input.entry.clientMessageId } } // `pending` is the host saying the message was written and is awaiting the @@ -153,7 +159,8 @@ export function disposeStructuredAgentSessionSendResult( submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' ), error: null, - blockedClientMessageId: input.blockedClientMessageId + blockedClientMessageId: input.blockedClientMessageId, + retryWithFreshClientMessageId: null } } @@ -170,6 +177,7 @@ export function disposeStructuredAgentSessionSendFailure( error: deliveryUnknown ? 'Message delivery is unconfirmed' : String(input.cause), blockedClientMessageId: deliveryUnknown ? input.blockedClientMessageId - : input.entry.clientMessageId + : input.entry.clientMessageId, + retryWithFreshClientMessageId: null } }