fix(native-chat): rotate immediately rejected retries

This commit is contained in:
Merge Sim
2026-09-11 16:50:38 -07:00
parent 8bea7c8372
commit fd8ea0ecbd
3 changed files with 25 additions and 9 deletions
@@ -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))
@@ -52,6 +52,7 @@ export function useStructuredAgentSessionOutbox(args: {
const dispatchingRef = useRef(false)
const dispatchGenerationRef = useRef(0)
const blockedIdRef = useRef<string | null>(null)
const retryWithFreshClientMessageIdRef = useRef<string | null>(null)
const probeAttemptsRef = useRef({ id: null as string | null, attempts: 0 })
const [error, setError] = useState<string | null>(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
? {
@@ -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
}
}