From 5c31caaf994af8536498786dc17215155403d3a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:10:40 -0700 Subject: [PATCH 1/6] fix(native-chat): keep a message the chat said was not sent held until its Retry A native-chat send the host refused (for example "Chats were saved by a newer Orca. Your message was not sent.") or that never reached the host showed "not sent" with a Retry button, but the hold that stopped it lived only in the outbox hook's memory. The message itself was saved in the outbox, so the next launch lifted the hold and sent it with no Retry; a copy the user retyped in the meantime was held behind it and went out as well. The hold is now read from the failure the message already saves: a queued entry that carries its last failure waits for the user's Retry, on this launch and every later one, and an entry saved by an earlier build in that shape is held too. The drain passes over a held message instead of stopping behind it, so what the user sends next goes out as they send it. Retry clears the saved failure. On a host from before accepted-send, a new agent owner observed while the chat is open still sends the refused message again, as before; a relaunch does not. A held message keeps its operation id, and a host refuses an id older than a day as expired for good, so its Retry could never go through; a new id could deliver a message an earlier attempt already delivered. Such a message now goes back to the composer with a notice to check the chat before sending it again, and leaves the outbox. --- ...tiveChatStructuredSession.test-harness.tsx | 1 - .../NativeChatStructuredSession.tsx | 10 +- ...tiveChatStructuredSessionDelivery.test.tsx | 1 - ...red-agent-session-delivery-notices.test.ts | 64 ++- ...ructured-agent-session-delivery-notices.ts | 22 +- ...ssion-outbox-dispatch.owner-change.test.ts | 6 +- ...tructured-agent-session-outbox-dispatch.ts | 32 +- .../structured-agent-session-outbox-retry.ts | 3 +- ...uctured-agent-session-queued-cards.test.ts | 18 +- .../structured-agent-session-queued-cards.ts | 12 +- ...agent-session-withdrawn-message-restore.ts | 16 +- ...d-agent-session-command-send-gate.test.tsx | 1 - ...ent-session-command-start-failure.test.tsx | 1 - ...tured-agent-session-context-usage.test.tsx | 1 - ...ed-agent-session-outbox-admission.test.tsx | 45 ++- ...ctured-agent-session-outbox-fence.test.tsx | 4 +- ...ructured-agent-session-outbox-ownership.ts | 6 +- ...nt-session-outbox-rejection-cause.test.tsx | 3 - ...gent-session-outbox-relaunch-hold.test.tsx | 377 ++++++++++++++++++ ...d-agent-session-outbox-withdrawal.test.tsx | 14 +- ...ent-session-outbox.draft-hand-off.test.tsx | 2 - ...ent-session-outbox.queue-delivery.test.tsx | 2 +- ...e-structured-agent-session-outbox.test.tsx | 2 - .../use-structured-agent-session-outbox.ts | 68 ++-- ...tured-agent-session-prompt-cancel.test.tsx | 1 - ...ctured-agent-session-prompt-state.test.tsx | 1 - ...uctured-agent-session-provisional.test.tsx | 1 - ...ed-agent-session-question-answers.test.tsx | 1 - ...use-structured-agent-session-stop.test.tsx | 9 +- ...tured-agent-session-write-refusal.test.tsx | 1 - ...tured-agent-session.queued-gating.test.tsx | 1 - .../use-structured-agent-session.test.tsx | 1 - .../use-structured-agent-session.ts | 27 +- ...-agent-session-accepted-send-capability.ts | 2 +- .../agent-session-refusal-retry.test.ts | 21 +- ...tured-agent-session-draft-hand-off.test.ts | 7 +- ...ructured-agent-session-outbox-admission.ts | 45 +++ ...ed-agent-session-outbox-retry-hold.test.ts | 79 ++++ ...ed-agent-session-outbox-stop-withdrawal.ts | 28 +- ...gent-session-outbox.queue-delivery.test.ts | 14 +- src/shared/structured-agent-session-outbox.ts | 51 +-- ...red-agent-session-send-disposition.test.ts | 64 +-- ...ructured-agent-session-send-disposition.ts | 68 ++-- 43 files changed, 792 insertions(+), 341 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx create mode 100644 src/shared/structured-agent-session-outbox-admission.ts create mode 100644 src/shared/structured-agent-session-outbox-retry-hold.test.ts diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index dd43358c30dc..f8a5156dbdc9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -175,7 +175,6 @@ export function createStructuredSessionMocks() { prompts: mocks.promptItems, outbox: outbox.outbox, submissions: mocks.submissions, - blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, isWorking: mocks.isWorking, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index c78c27419a1e..cef7142094f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -131,20 +131,12 @@ export function NativeChatStructuredSession( () => structuredAgentSessionDeliveryNotices( controller.outbox, - controller.blockedClientMessageId, agentLabel, retryDelivery, rejectionRows, startFailures ), - [ - controller.outbox, - controller.blockedClientMessageId, - agentLabel, - retryDelivery, - rejectionRows, - startFailures - ] + [controller.outbox, agentLabel, retryDelivery, rejectionRows, startFailures] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index d20dda5a1692..29eb0132385d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -80,7 +80,6 @@ vi.mock('./use-structured-agent-session', async () => { prompts: mocks.promptItems, outbox: outbox.outbox, submissions: mocks.submissions, - blockedClientMessageId: outbox.blockedClientMessageId, send: outbox.send, retry: outbox.retry, isWorking: false, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index b50122f5108c..286b1e54acce 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -38,13 +38,11 @@ function entry( function texts( outbox: StructuredAgentSessionOutboxEntry[], - blocked: string | null = null, submissions: readonly AgentJournalSubmission[] = [], startFailures: readonly AgentSessionFailureFact[] = [] ): Record { const notices = structuredAgentSessionDeliveryNotices( outbox, - blocked, 'Claude', () => {}, submissions, @@ -71,7 +69,6 @@ describe('the notice on each message that did not go through', () => { } }) ], - null, 'Claude', retry, [], @@ -92,25 +89,22 @@ describe('the notice on each message that did not go through', () => { expect(retry).toHaveBeenCalledExactlyOnceWith('second') }) - it('chooses the words from the saved refusal on the message the queue stopped on', () => { + it('chooses the words from the saved refusal on a refused message', () => { expect( - texts( - [ - entry('held', { - lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } - }) - ], - 'held' - ) + texts([ + entry('held', { + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } + }) + ]) ).toEqual({ [agentJournalSubmissionKey('held')]: "The agent couldn't restart. Your message was not sent." }) }) // The same rule as a rejected row's: its own Retry is the resend step, and any other step stays. - it('leaves a retry step to the Retry beside the message the queue stopped on', () => { + it('leaves a retry step to the Retry beside a refused message', () => { const held = (lastFailure: StructuredAgentSessionOutboxEntry['lastFailure']) => - texts([entry('held', { lastFailure })], 'held') + texts([entry('held', { lastFailure })]) expect( held({ kind: 'refused', @@ -137,35 +131,37 @@ describe('the notice on each message that did not go through', () => { expect(texts([entry('doubt', { state: 'unconfirmed' })])).toEqual({ [agentJournalSubmissionKey('doubt')]: 'Message delivery is unconfirmed.' }) - expect(texts([entry('bare')], 'bare')).toEqual({ + expect(texts([entry('bare', { state: 'rejected' })])).toEqual({ [agentJournalSubmissionKey('bare')]: 'Message was not sent.' }) }) it('never says a send attempted before a Stop was not sent: the host may hold it', () => { const interrupted = entry('stopped', { state: 'queued', lastAttemptAt: 5, outlivedStop: true }) - expect(texts([interrupted], 'stopped')).toEqual({ + expect(texts([interrupted])).toEqual({ [agentJournalSubmissionKey('stopped')]: 'Message delivery is unconfirmed.' }) const neverSent = entry('unsent', { state: 'queued', outlivedStop: true }) - expect(texts([neverSent], 'unsent')).toEqual({ + expect(texts([neverSent])).toEqual({ [agentJournalSubmissionKey('unsent')]: 'Message was not sent.' }) }) // The drain's own rule: a message behind the one the queue stopped on is only waiting, so it says - // nothing. A rejected message holds nothing up and keeps its words. - it('says why on the message the queue stopped on and on every rejected one', () => { + // nothing. A rejected or refused message holds nothing up and keeps its words. + it('says why on the message the queue stopped on and on every rejected or refused one', () => { expect( texts([ entry('sent', { state: 'dispatching' }), entry('rejected', { state: 'rejected' }), + entry('failed', { lastFailure: { kind: 'failed' } }), entry('stuck', { state: 'unconfirmed' }), entry('behind', { state: 'unconfirmed' }), entry('queued') ]) ).toEqual({ [agentJournalSubmissionKey('rejected')]: 'Message was not sent.', + [agentJournalSubmissionKey('failed')]: 'Your message was not sent.', [agentJournalSubmissionKey('stuck')]: 'Message delivery is unconfirmed.' }) }) @@ -173,18 +169,11 @@ describe('the notice on each message that did not go through', () => { // Its Retry would put it back in the queue to wait unseen behind the stopped message. it('keeps a rejected message its words but not its Retry while the queue is stopped', () => { const retry = vi.fn() - for (const [outbox, blocked] of [ - [[entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], null], - [[entry('rejected', { state: 'rejected' }), entry('held')], 'held'] - ] as const) { - const notices = structuredAgentSessionDeliveryNotices( - [...outbox], - blocked, - 'Claude', - retry, - [], - [] - ) + for (const outbox of [ + [entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], + [entry('rejected', { state: 'rejected' }), entry('held', { outlivedStop: true })] + ]) { + const notices = structuredAgentSessionDeliveryNotices(outbox, 'Claude', retry, [], []) expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ text: 'Message was not sent.' }) @@ -206,7 +195,7 @@ describe('the notice on each message that did not go through', () => { [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.', [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.' }) - expect(texts([startFailed('rejected'), entry('held')], 'held')).toMatchObject({ + expect(texts([startFailed('rejected'), entry('held', { outlivedStop: true })])).toMatchObject({ [agentJournalSubmissionKey('rejected')]: 'Claude stopped before it finished starting. Send your message to try again.' }) @@ -296,7 +285,6 @@ describe('the notice on each message that did not go through', () => { expect( texts( facts.map(([id]) => rejected(id)), - null, facts.map(([id, fact]) => recorded(id, fact)) ) ).toEqual( @@ -357,7 +345,6 @@ describe('the notice on each message that did not go through', () => { }) }) ], - null, [ { clientMessageId: 'recorded', @@ -443,7 +430,6 @@ describe('the notice on each message that did not go through', () => { rejected('second', startFailed), rejected('other', otherRefusal) ], - null, [ recorded('first', startFailed), recorded('second', startFailed), @@ -461,12 +447,12 @@ describe('the notice on each message that did not go through', () => { it('keeps the full notice when the rejection is not loaded, or no start row states it', () => { const shown = "Claude couldn't start. Start a new chat to continue." - expect(texts([rejected('first', startFailed)], null, [], [startFailed])).toEqual({ + expect(texts([rejected('first', startFailed)], [], [startFailed])).toEqual({ [agentJournalSubmissionKey('first')]: 'Written by the host.' }) - expect( - texts([rejected('first', startFailed)], null, [recorded('first', startFailed)], []) - ).toEqual({ [agentJournalSubmissionKey('first')]: shown }) + expect(texts([rejected('first', startFailed)], [recorded('first', startFailed)], [])).toEqual( + { [agentJournalSubmissionKey('first')]: shown } + ) }) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 7cf515cc75a2..e37e90a8534f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -3,8 +3,8 @@ // Derived from the outbox on every render and never stored: each failed or held message carries // its own typed failure, so each row words its own reason. Read through the drain's own rule: while // the queue is stopped, only the message it stopped on has a Retry; another's would wait unseen -// behind it. One waiting behind says nothing; a rejected message holds nothing up, so it keeps its -// words and gets its Retry once the queue moves. +// behind it. One waiting behind says nothing; a rejected or refused message holds nothing up, so it +// keeps its words and gets its Retry once the queue moves. // // A message the host recorded and then rejected is worded from the journal's own fact, found by id; // the message keeps only a smaller copy, read when its submission is not loaded. A rejection that @@ -23,10 +23,11 @@ import type { } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { admitStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' + structuredAgentSessionEntryHeldForRetry +} from '../../../../shared/structured-agent-session-outbox-admission' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' import { translate } from '@/i18n/i18n' @@ -115,11 +116,10 @@ function deliveryNoticeText( ) } -/** Keyed by the message id the transcript renders each entry under. `blockedClientMessageId` is - * the entry a refusal stopped the queue on; `agentName` is the chat's agent, for the words. */ +/** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's + * agent, for the words. */ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], - blockedClientMessageId: string | null, agentName: string, retry: (clientMessageId: string) => void, /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ @@ -127,7 +127,7 @@ export function structuredAgentSessionDeliveryNotices( /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly AgentSessionFailureFact[] ): ReadonlyMap { - const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) + const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null const rejected = new Map( submissions @@ -136,7 +136,11 @@ export function structuredAgentSessionDeliveryNotices( ) const notices = new Map() for (const entry of outbox) { - if (entry.state === 'rejected' || entry.clientMessageId === held) { + if ( + entry.state === 'rejected' || + structuredAgentSessionEntryHeldForRetry(entry) || + entry.clientMessageId === held + ) { // Its own Retry is the step, so the words leave out sending again. const retryControl = held === null || entry.clientMessageId === held const text = deliveryNoticeText( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts index ef5fc10aa941..d74910154188 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.owner-change.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest' import { - admitStructuredAgentSessionOutboxEntry, createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission' import { requeueInterruptedStructuredAgentSessionDispatches } from './structured-agent-session-outbox-dispatch' function dispatching( @@ -33,8 +33,8 @@ describe('requeue after an owner change', () => { [dispatching('stopped', { outlivedStop: true })], 1 ) - expect(admitStructuredAgentSessionOutboxEntry([stopped], null).state).toBe('blocked') + expect(admitStructuredAgentSessionOutboxEntry([stopped]).state).toBe('blocked') const [plain] = requeueInterruptedStructuredAgentSessionDispatches([dispatching('plain')], 1) - expect(admitStructuredAgentSessionOutboxEntry([plain], null).state).toBe('dispatch') + expect(admitStructuredAgentSessionOutboxEntry([plain]).state).toBe('dispatch') }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index e15e532c81b8..916abd3ec765 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -20,6 +20,7 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -75,6 +76,20 @@ export function requeueInterruptedStructuredAgentSessionDispatches( ) } +/** An older host's new owner is its word that a send it refused may now land (see + * `useStructuredAgentSessionOutboxOwnerChange`), so what it refused goes out again, same id. */ +export function releaseStructuredAgentSessionRefusalsForNewOwner( + entries: StructuredAgentSessionOutboxEntry[] +): StructuredAgentSessionOutboxEntry[] { + return entries.map((entry) => { + if (!structuredAgentSessionEntryHeldForRetry(entry)) { + return entry + } + const { lastFailure: _released, ...released } = entry + return released + }) +} + export function dispatchStructuredAgentSessionOutboxEntry(args: { next: StructuredAgentSessionOutboxEntry persisted: readonly StructuredAgentSessionOutboxEntry[] @@ -84,7 +99,6 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { dispatchGeneration: number dispatchGenerationRef: MutableRef inFlightIdRef: MutableRef - blockedIdRef: MutableRef outboxRef: MutableRef setOutbox: (entries: StructuredAgentSessionOutboxEntry[]) => void setError: (error: string | null) => void @@ -100,7 +114,14 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { ) if (!writeOutbox(args.sessionId, staged)) { args.inFlightIdRef.current = null - args.blockedIdRef.current = args.next.clientMessageId + // Held for Retry in memory: the save that would record the hold is what failed. + const held = updateStructuredAgentSessionOutboxEntry( + args.outboxRef.current, + args.next.clientMessageId, + (entry) => ({ ...entry, lastFailure: { kind: 'failed' } }) + ) + args.outboxRef.current = held + args.setOutbox(held) args.setError('Message could not be saved to the outbox') return false } @@ -119,7 +140,6 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { disposeStructuredAgentSessionSendResult({ entries: args.outboxRef.current, entry: args.next, - blockedClientMessageId: args.blockedIdRef.current, result, createOperationId: args.createOperationId }) @@ -139,11 +159,7 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { return false } - const input = { - entries: args.outboxRef.current, - entry: args.next, - blockedClientMessageId: args.blockedIdRef.current - } + const input = { entries: args.outboxRef.current, entry: args.next } const thrown = readAgentSessionErrorRefusal(caught) const refusal = thrown ? agentSessionRefusalFailure(thrown) : undefined args.applyDisposition( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts index f543efcacd8e..d5c51909335e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -66,9 +66,10 @@ export function retryStructuredAgentSessionOutboxEntry(args: { setOutbox(next) } -/** The user's own Retry is what a Stop left the entry waiting for. */ +/** The user's own Retry is what a Stop, or a failure saved on the message, left it waiting for. */ function retriedByUser({ outlivedStop: _retried, + lastFailure: _sentAgain, ...entry }: StructuredAgentSessionOutboxEntry): StructuredAgentSessionOutboxEntry { return entry diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index db229531ec9c..995d80247699 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -186,21 +186,21 @@ describe('queued message cards', () => { entry('a', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: 'queue-if-active' }), entry('b') ] - expect(ids(outboxOutsideQueuedCards(inFlight, [], true, null, QUEUEING))).toEqual([]) - expect(ids(outboxOutsideQueuedCards(inFlight, [], false, null, QUEUEING))).toEqual(['a', 'b']) - // Refused and held for Retry: its text, and everything waiting behind it, stays in view. + expect(ids(outboxOutsideQueuedCards(inFlight, [], true, QUEUEING))).toEqual([]) + expect(ids(outboxOutsideQueuedCards(inFlight, [], false, QUEUEING))).toEqual(['a', 'b']) + // Refused and held for Retry: its text stays in view, and what follows it is on its way. const refused = [entry('a', { lastFailure: { kind: 'failed' } }), entry('b')] - expect(ids(outboxOutsideQueuedCards(refused, [], true, 'a', QUEUEING))).toEqual(['a', 'b']) + expect(ids(outboxOutsideQueuedCards(refused, [], true, QUEUEING))).toEqual(['a']) // A rejected send holds nothing up: what follows it is still on its way to a card. const rejected = [ entry('a', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }), entry('b') ] - expect(ids(outboxOutsideQueuedCards(rejected, [], true, null, QUEUEING))).toEqual(['a']) + expect(ids(outboxOutsideQueuedCards(rejected, [], true, QUEUEING))).toEqual(['a']) const unconfirmed = [entry('a', { state: 'unconfirmed' }), entry('b')] - expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, null, QUEUEING))).toEqual(['a', 'b']) + expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, QUEUEING))).toEqual(['a', 'b']) // Once the host visibly holds it, it is a card whatever this queue last heard. - expect(ids(outboxOutsideQueuedCards(unconfirmed, ['a'], true, null, QUEUEING))).toEqual(['b']) + expect(ids(outboxOutsideQueuedCards(unconfirmed, ['a'], true, QUEUEING))).toEqual(['b']) }) it('hides a send only by what its request carries: a plain one is always a bubble', () => { @@ -222,12 +222,12 @@ describe('queued message cards', () => { entries.map((candidate) => candidate.clientMessageId) // The capability is unknown: a new send goes out plain, so it stays in view. const unknown = { capability: 'unknown', enabled: true } as const - expect(ids(outboxOutsideQueuedCards([entry('new')], [], true, null, unknown))).toEqual(['new']) + expect(ids(outboxOutsideQueuedCards([entry('new')], [], true, unknown))).toEqual(['new']) // A send that went out plain stays a bubble; one that went out queued replays queued. const sent = [ entry('plain', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: null }), entry('queued', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: 'queue-if-active' }) ] - expect(ids(outboxOutsideQueuedCards(sent, [], true, null, unknown))).toEqual(['plain']) + expect(ids(outboxOutsideQueuedCards(sent, [], true, unknown))).toEqual(['plain']) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index 8973adf5fc69..e9d1f394b9af 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -9,10 +9,11 @@ import { structuredAgentSessionEntryAsksToQueue, type StructuredAgentSessionQueueDelivery } from '../../../../shared/structured-agent-session-outbox-delivery' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { admitStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' + structuredAgentSessionEntryHeldForRetry +} from '../../../../shared/structured-agent-session-outbox-admission' /** Why a card is not on its way right now; decides the caption under the text. */ export type QueuedMessageCardHold = @@ -103,23 +104,24 @@ export function newestSteerableQueuedMessageCard( * read from what its request carries — otherwise it paints * in the transcript until the queued answer retires it. A plain send stays a bubble. From * the entry the drain is stopped on (read through the drain's own rule), nothing is on its - * way: those stay bubbles so their text is visible beside the Retry row. + * way, nor is one held for its Retry: those stay bubbles so their text is visible beside the + * Retry row. */ export function outboxOutsideQueuedCards( outbox: readonly StructuredAgentSessionOutboxEntry[], heldIds: readonly string[], isWorking: boolean, - blockedClientMessageId: string | null, host: StructuredAgentSessionQueueDelivery ): readonly StructuredAgentSessionOutboxEntry[] { const held = new Set(heldIds) - const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedClientMessageId) + const admission = admitStructuredAgentSessionOutboxEntry(outbox) const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 const next = outbox.filter((entry, index) => { const onItsWay = isWorking && (stalledFrom === -1 || index < stalledFrom) && (entry.state === 'queued' || entry.state === 'dispatching') && + !structuredAgentSessionEntryHeldForRetry(entry) && structuredAgentSessionEntryAsksToQueue(entry, host) return !held.has(entry.clientMessageId) && !onItsWay }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index c5bf4600aacb..68facefde117 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -10,15 +10,15 @@ import { appendNativeChatAttachmentCache } from './use-native-chat-composer-atta * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, * after whatever is there. Called before the entries leave storage, so a failure between the two * repeats the text rather than losing it. Only this client's outbox holds them, so no other viewer - * gets them. + * gets them. False when no composer shows this session. */ function restoreWithdrawnMessages( sessionId: string, composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] -): void { - if (!composerScopeKey || withdrawn.length === 0) { - return +): boolean { + if (!composerScopeKey) { + return false } // What storage no longer holds was already given back by whichever view dropped it first. const held = new Set( @@ -42,6 +42,7 @@ function restoreWithdrawnMessages( ) ) } + return true } export function useStructuredAgentSessionWithdrawnRestore( @@ -56,6 +57,8 @@ export function useStructuredAgentSessionWithdrawnRestore( ) => void /** Entries a Stop took out of the outbox here, before the host held them. */ byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void + /** Entries no send can deliver any more; false when no composer shows this session. */ + toComposer: (entries: readonly StructuredAgentSessionOutboxEntry[]) => boolean } { return useMemo( () => ({ @@ -76,7 +79,10 @@ export function useStructuredAgentSessionWithdrawnRestore( entries.filter((entry) => withdrawn.has(entry.clientMessageId)) ) }, - byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) + byStop: (entries) => { + restoreWithdrawnMessages(sessionId, composerScopeKey, entries) + }, + toComposer: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) }), [composerScopeKey, sessionId] ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx index 6fc339e90c87..1c5044265a16 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-send-gate.test.tsx @@ -33,7 +33,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: () => 'operation-1', useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: mocks.outboxSend, retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx index 8700678c030e..1a76607a4848 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-command-start-failure.test.tsx @@ -38,7 +38,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: () => 'operation-1', useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx index a967e7a26a3d..7b45c6bc9d7a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-context-usage.test.tsx @@ -37,7 +37,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: vi.fn(), useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index feb9f898ce25..27a1a055a158 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -23,6 +23,8 @@ import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-se import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' const LOCAL_TARGET = { kind: 'local' } as const @@ -51,6 +53,13 @@ function sentTexts(): (string | undefined)[] { return mocks.call.mock.calls.map((call) => requestText(call[2])) } +/** The messages that wait for their own Retry. */ +function heldIds(outbox: readonly StructuredAgentSessionOutboxEntry[]): string[] { + return outbox + .filter(structuredAgentSessionEntryHeldForRetry) + .map((entry) => entry.clientMessageId) +} + function submissionResult( clientMessageId: string, dispatchState: 'pending' | 'accepted', @@ -155,17 +164,26 @@ describe('structured agent session outbox admission', () => { expect(mocks.call).toHaveBeenCalledTimes(1) }) - it('keeps a refused head blocking the queue', async () => { - mocks.call.mockResolvedValue(refusedResult('agent_session_ownership_unknown')) + // A refused message lands only by its own Retry, so nothing it could be reordered around. + it('sends a message past a refused one, which waits for its own Retry', async () => { + mocks.call.mockImplementation((_target, _method, params) => + Promise.resolve( + requestText(params) === 'first' + ? refusedResult('agent_session_ownership_unknown') + : submissionResult(requestId(params), 'accepted', 20) + ) + ) const { result } = renderOutbox() act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + await waitFor(() => expect(heldIds(result.current.outbox)).toHaveLength(1)) + const refusedId = heldIds(result.current.outbox)[0] act(() => expect(result.current.send('second')).toBe(true)) - await settleTimers(200) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settleTimers(50) - expect(sentTexts()).not.toContain('second') - expect(result.current.blockedClientMessageId).toBe(result.current.outbox[0]?.clientMessageId) + expect(sentTexts()).toEqual(['first', 'second']) + expect(heldIds(result.current.outbox)).toEqual([refusedId]) }) it.each(['accepted', 'pending'] as const)( @@ -250,8 +268,8 @@ describe('structured agent session outbox admission', () => { await waitFor(() => expect(result.current.outbox.at(-1)?.state).toBe('rejected')) } act(() => expect(result.current.send('held')).toBe(true)) - await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) - const heldId = result.current.blockedClientMessageId + await waitFor(() => expect(heldIds(result.current.outbox)).toHaveLength(1)) + const heldId = heldIds(result.current.outbox)[0] const [x, a, c] = result.current.outbox // x goes out and stays in flight, so a, retried after it, waits queued ahead of the held one. @@ -262,7 +280,6 @@ describe('structured agent session outbox admission', () => { // The drain would send a next, so the queue reads as moving and c offers its own Retry. const notices = structuredAgentSessionDeliveryNotices( result.current.outbox, - result.current.blockedClientMessageId, 'Claude', () => {}, [], @@ -271,12 +288,12 @@ describe('structured agent session outbox admission', () => { expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined() act(() => result.current.retry(c!.clientMessageId)) - expect(result.current.blockedClientMessageId).toBe(heldId) + expect(heldIds(result.current.outbox)).toEqual([heldId]) await act(async () => inFlight.resolve(submissionResult(x!.clientMessageId, 'accepted', 20))) await waitFor(() => expect(result.current.outbox).toHaveLength(1)) await settleTimers(50) expect(sentTexts().filter((text) => text === 'held')).toHaveLength(1) - expect(result.current.blockedClientMessageId).toBe(heldId) + expect(heldIds(result.current.outbox)).toEqual([heldId]) }) it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => { @@ -302,8 +319,8 @@ describe('structured agent session outbox admission', () => { act(() => expect(result.current.send('first')).toBe(true)) await waitFor(() => expect(result.current.outbox[0]?.state).toBe('dispatching')) act(() => expect(result.current.send('held')).toBe(true)) - await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) - const heldId = result.current.blockedClientMessageId + await waitFor(() => expect(heldIds(result.current.outbox)).toHaveLength(1)) + const heldId = heldIds(result.current.outbox)[0] const firstId = String(result.current.outbox[0]?.clientMessageId) const unknown: AgentJournalSubmission = { ...submissionResult(firstId, 'pending', 10).value.submission, @@ -316,6 +333,6 @@ describe('structured agent session outbox admission', () => { await waitFor(() => expect(result.current.outbox).toHaveLength(1)) await settleTimers(50) expect(sentTexts()).toEqual(['first', 'held', 'first']) - expect(result.current.blockedClientMessageId).toBe(heldId) + expect(heldIds(result.current.outbox)).toEqual([heldId]) }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx index 61b5ea95e727..04789bace133 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx @@ -101,12 +101,12 @@ describe('an outbox on a host that accepts a send before any agent has it', () = expect(mocks.call).toHaveBeenCalledTimes(1) }) - it('keeps a blocked head blocked across a fence change; only Retry sends it', async () => { + it('keeps a failed send held across a fence change; only Retry sends it', async () => { mocks.call.mockRejectedValueOnce(new Error('send failed')).mockResolvedValue({ ok: true }) const { result, rerender } = render() act(() => expect(result.current.send('hello')).toBe(true)) - await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) rerender({ fence: 2 }) await settle() expect(mocks.call).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts index 8a2c30b82794..8c4985189266 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-ownership.ts @@ -17,7 +17,6 @@ export function useStructuredAgentSessionOutboxOwnership(args: { /** Ids of the host's published drafts; an entry with one of these ids is host-owned. */ queuedMessageIds: readonly string[] | undefined outboxRef: { current: StructuredAgentSessionOutboxEntry[] } - blockedIdRef: { current: string | null } /** The send in flight and its generation: a host that holds that send answered it. */ inFlightIdRef: { current: string | null } dispatchGenerationRef: { current: number } @@ -27,14 +26,13 @@ export function useStructuredAgentSessionOutboxOwnership(args: { /** Stop's local step, before its RPC, so the drain has nothing left to send after it. */ withdrawUnsent: () => void } { - const { blockedIdRef, outboxRef, queuedMessageIds, restoreWithdrawn, sessionId, setOutbox } = args + const { outboxRef, queuedMessageIds, restoreWithdrawn, sessionId, setOutbox } = args const { dispatchGenerationRef, inFlightIdRef, submissions } = args const withdrawUnsent = useCallback((): void => { const next = withdrawUnsentStructuredAgentSessionOutboxEntries( outboxRef.current, submissions, - blockedIdRef.current, inFlightIdRef.current ) const current = outboxRef.current @@ -47,7 +45,7 @@ export function useStructuredAgentSessionOutboxOwnership(args: { outboxRef.current = next setOutbox(next) writeOutbox(sessionId, next) - }, [blockedIdRef, inFlightIdRef, outboxRef, restoreWithdrawn, sessionId, setOutbox, submissions]) + }, [inFlightIdRef, outboxRef, restoreWithdrawn, sessionId, setOutbox, submissions]) // Drop host-owned entries without a restore: the published card is the text now. const retire = useCallback( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index f3386affd1af..4f8f001b14b7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -108,7 +108,6 @@ describe('a send the host rejected because the agent never started', () => { // Settled as not delivered: it waits for Retry and holds no later message up. expect(result.current.error).toBeNull() expect(result.current.outbox[0]?.state).toBe('rejected') - expect(result.current.blockedClientMessageId).toBeNull() }) it('sends a new message past one the host could not start the agent for, without resending it', async () => { @@ -181,7 +180,6 @@ describe('a send the host rejected because the agent never started', () => { await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) expect(shownFailure(result.current.outbox[0])).toBe(reason) expect(result.current.error).toBeNull() - expect(result.current.blockedClientMessageId).toBeNull() // Retry is a new message with the same text: a fresh id, sent once. act(() => result.current.retry(id)) @@ -448,7 +446,6 @@ describe('a send refused while its agent restarted', () => { rerender({ fence: 3, submissions: [submission(id, 'accepted')] }) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) expect(result.current.error).toBeNull() - expect(result.current.blockedClientMessageId).toBeNull() }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx new file mode 100644 index 000000000000..b946365ba0d6 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -0,0 +1,377 @@ +// @vitest-environment happy-dom + +// A message the chat said was not sent, with a Retry beside it, waits for that Retry. The hold is +// read from the saved message, so quitting and reopening Orca (a new mount over the same storage) +// holds it exactly as the refusal did. + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' +import { STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE } from '../../../../shared/structured-agent-session-send-disposition' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' + +const SESSION = 'session-1' +const NEWER_ORCA_WORDS = + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + +type SendRequest = { + body?: { blocks?: { text?: string }[] } + envelope?: { clientOperationId?: string } +} + +function requestId(params: SendRequest | undefined): string { + return String(params?.envelope?.clientOperationId) +} + +function sentIds(): string[] { + return mocks.call.mock.calls.map((call) => requestId(call[2])) +} + +function newerOrcaRefusal() { + return { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + } +} + +function refusal(code: AgentSessionWireRefusalCode) { + return { ok: false, refusal: { code, message: code } } +} + +function accepted(clientMessageId: string) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: 1, + resolvedAt: 1 + } + } + } +} + +function hostAccepts(): void { + mocks.call.mockImplementation((_target, _method, params: SendRequest) => + Promise.resolve(accepted(requestId(params))) + ) +} + +function mount(fence = 1) { + return renderHook( + ({ fence: current }: { fence: number }) => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: { kind: 'local' }, + fence: current, + submissions: [] + }), + { initialProps: { fence } } + ) +} + +type Outbox = ReturnType['result']['current'] + +function notices(outbox: Outbox) { + return structuredAgentSessionDeliveryNotices(outbox.outbox, 'Claude', outbox.retry, [], []) +} + +function noticeFor(outbox: Outbox, clientMessageId: string) { + return notices(outbox).get(agentJournalSubmissionKey(clientMessageId)) +} + +/** Long enough for any effect a mount or a state change schedules to have sent. */ +async function settle(): Promise { + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) +} + +describe('a message the host refused, across a relaunch', () => { + afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + }) + + it.each(['returned', 'thrown'] as const)( + 'is not sent on its own after a relaunch; its Retry sends it (refusal %s)', + async (shape) => { + if (shape === 'returned') { + mocks.call.mockResolvedValueOnce(newerOrcaRefusal()) + } else { + // As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`). + mocks.call.mockRejectedValueOnce( + new RuntimeRpcCallError({ + id: 'req-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + } + } + }) + ) + } + const before = mount() + act(() => expect(before.result.current.send('hello')).toBe(true)) + await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined()) + const refusedId = sentIds()[0]! + expect(noticeFor(before.result.current, refusedId)?.text).toBe(NEWER_ORCA_WORDS) + before.unmount() + + // The user updates Orca and opens the chat again; the host now takes sends. + hostAccepts() + const after = mount() + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + const notice = noticeFor(after.result.current, refusedId) + expect(notice?.text).toBe(NEWER_ORCA_WORDS) + expect(notice?.onRetry).toBeDefined() + + act(() => notice?.onRetry?.()) + await waitFor(() => expect(after.result.current.outbox).toHaveLength(0)) + // The refusal recorded nothing, so the Retry goes out under the refused id. + expect(sentIds()).toEqual([refusedId, refusedId]) + } + ) + + it('sends a message typed again after a refusal once, not a second time after a relaunch', async () => { + // The chat's history is from a newer Orca: every send is refused until the user updates. + mocks.call.mockResolvedValue(newerOrcaRefusal()) + const before = mount() + act(() => expect(before.result.current.send('hello')).toBe(true)) + await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined()) + // The user types the same message again rather than pressing Retry. + act(() => expect(before.result.current.send('hello')).toBe(true)) + await settle() + const refusedBeforeUpdate = mocks.call.mock.calls.length + before.unmount() + + hostAccepts() + const after = mount() + await settle() + // Nothing goes out on its own: each message still says it was not sent, with its own Retry. + expect(mocks.call).toHaveBeenCalledTimes(refusedBeforeUpdate) + expect(after.result.current.outbox).toHaveLength(2) + for (const entry of after.result.current.outbox) { + expect(noticeFor(after.result.current, entry.clientMessageId)?.onRetry).toBeDefined() + } + + // One Retry delivers the message once; the other copy stays unsent. + act(() => after.result.current.retry(after.result.current.outbox[1]!.clientMessageId)) + await waitFor(() => expect(after.result.current.outbox).toHaveLength(1)) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(refusedBeforeUpdate + 1) + }) + + it('keeps a send that never reached the host held across a relaunch', async () => { + mocks.call.mockRejectedValueOnce(new Error('send failed')) + const before = mount() + act(() => expect(before.result.current.send('hello')).toBe(true)) + await waitFor(() => + expect(before.result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' }) + ) + const failedId = sentIds()[0]! + before.unmount() + + hostAccepts() + const after = mount() + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + expect(noticeFor(after.result.current, failedId)?.onRetry).toBeDefined() + }) + + it('holds a refused message an earlier Orca saved, in the shape that build writes', async () => { + // Exactly what a build from before this hold leaves behind after the refusal: no new field. + localStorage.setItem( + `orca:desktopStructuredAgentSessionOutbox:v1:${SESSION}`, + JSON.stringify([ + { + clientMessageId: 'op-refused', + sessionId: SESSION, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: 2, + retryAfterUnknownSubmittedAt: null, + lastFailure: { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + } + ]) + ) + hostAccepts() + const { result } = mount() + await settle() + expect(mocks.call).not.toHaveBeenCalled() + expect(noticeFor(result.current, 'op-refused')?.text).toBe(NEWER_ORCA_WORDS) + }) +}) + +// An older host restarts the agent inside a send and refuses it unrecorded when that fails; a new +// fence while the chat is open is still its word to send again. A relaunch is not. +describe('a message an older host refused, across a relaunch', () => { + afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([]) + }) + + it('is not sent on its own when the chat reopens on a moved fence', async () => { + mocks.call.mockResolvedValueOnce(refusal('agent_session_checkpoint_stale')) + const before = mount(1) + act(() => expect(before.result.current.send('hello')).toBe(true)) + await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined()) + before.unmount() + + hostAccepts() + mount(3) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + }) + + it('still sends it again when the fence moves while the chat is open', async () => { + hostAccepts() + mocks.call.mockResolvedValueOnce(refusal('agent_session_checkpoint_stale')) + const { result, rerender } = mount(1) + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) + + rerender({ fence: 3 }) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(sentIds()[1]).toBe(sentIds()[0]) + }) +}) + +describe('a message whose send could not be saved before it went out', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + setLocalRuntimeCapabilitiesForTests(null) + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + }) + + it('waits for its Retry, and does not hold back the next message', async () => { + hostAccepts() + const save = localStorage.setItem.bind(localStorage) + // The send saves; the save that marks it on its way out fails. + const setItem = vi + .spyOn(localStorage, 'setItem') + .mockImplementationOnce(save) + .mockImplementationOnce(() => { + throw new Error('storage full') + }) + const { result } = mount() + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => + expect(result.current.error).toBe('Message could not be saved to the outbox') + ) + setItem.mockRestore() + const firstId = result.current.outbox[0]!.clientMessageId + + act(() => expect(result.current.send('second')).toBe(true)) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settle() + expect(mocks.call).toHaveBeenCalledOnce() + expect(sentIds()).not.toContain(firstId) + expect(noticeFor(result.current, firstId)?.onRetry).toBeDefined() + }) +}) + +// A host forgets an operation id a day after it was made; after that the kept id is refused for +// good, and a new one could deliver a message an earlier attempt already delivered. +describe('a held message retried after its id expired', () => { + afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + clearNativeChatDraftCacheForTests() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + }) + + it('goes back to the composer with a notice, and is not sent again', async () => { + mocks.call.mockResolvedValueOnce(newerOrcaRefusal()) + mocks.call.mockResolvedValue({ + ok: false, + refusal: { + code: 'agent_session_operation_expired', + message: 'Operation expired.', + details: { reason: 'operationExpired' } + } + }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: { kind: 'local' }, + fence: 1, + submissions: [], + composerScopeKey: 'pane-1' + }) + ) + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) + + act(() => result.current.retry(result.current.outbox[0]!.clientMessageId)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(readNativeChatDraftCache('pane-1')).toBe('hello') + expect(result.current.error).toBe(STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx index f188c01919fa..1c7d69f1aa70 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx @@ -120,7 +120,7 @@ describe('a Stop withdrawing what the host does not hold', () => { ] expect( - withdrawUnsentStructuredAgentSessionOutboxEntries(entries, [pending('held')], null, null).map( + withdrawUnsentStructuredAgentSessionOutboxEntries(entries, [pending('held')], null).map( (candidate) => candidate.clientMessageId ) ).toEqual(['held', 'refused']) @@ -128,21 +128,19 @@ describe('a Stop withdrawing what the host does not hold', () => { it('leaves every message that waits on its Retry, not only a refused one', () => { const entries = [ - entry('blocked', 'queued'), + { ...entry('blocked', 'queued'), lastFailure: { kind: 'failed' as const } }, { ...entry('retried-in-doubt', 'unconfirmed'), retryAfterUnknownSubmittedAt: 10 }, entry('probed-in-doubt', 'unconfirmed'), entry('local', 'queued') ] expect( - withdrawUnsentStructuredAgentSessionOutboxEntries(entries, [], 'blocked', null).map( + withdrawUnsentStructuredAgentSessionOutboxEntries(entries, [], null).map( (candidate) => candidate.clientMessageId ) ).toEqual(['blocked', 'retried-in-doubt']) - expect(hasUnsentStructuredAgentSessionOutboxEntry(entries.slice(0, 2), [], 'blocked')).toBe( - false - ) - expect(hasUnsentStructuredAgentSessionOutboxEntry(entries, [], 'blocked')).toBe(true) + expect(hasUnsentStructuredAgentSessionOutboxEntry(entries.slice(0, 2), [])).toBe(false) + expect(hasUnsentStructuredAgentSessionOutboxEntry(entries, [])).toBe(true) }) it('keeps a message whose send failed for its Retry', async () => { @@ -156,7 +154,7 @@ describe('a Stop withdrawing what the host does not hold', () => { }) ) act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.blockedClientMessageId).not.toBeNull()) + await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' })) act(() => result.current.withdrawUnsent()) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx index 19c2c48206bc..6aef5b03cf11 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx @@ -129,7 +129,6 @@ describe('an outbox entry the host handed off as a queued draft', () => { ] }) await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) - expect(view.result.current.blockedClientMessageId).toBeNull() expect(readNativeChatDraftCache('scope')).toBe('') }) @@ -196,7 +195,6 @@ describe('an outbox entry the host handed off as a queued draft', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) await act(async () => new Promise((resolve) => setTimeout(resolve, 20))) expect(view.result.current.outbox).toEqual([]) - expect(view.result.current.blockedClientMessageId).toBeNull() expect(view.result.current.error).toBeNull() expect(readNativeChatDraftCache('scope')).toBe('') act(() => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx index 7bc986279aaf..39aa834e86ce 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx @@ -312,7 +312,7 @@ async function attemptedQueueSend() { { initialProps: { capability: SUPPORTED } } ) expect(view.result.current.send('follow-up')).toBe(true) - await waitFor(() => expect(view.result.current.blockedClientMessageId).not.toBeNull()) + await waitFor(() => expect(view.result.current.outbox[0]?.lastFailure).toBeDefined()) expect(mocks.call.mock.calls[0]?.[2]?.delivery).toBe('queue-if-active') mocks.call.mockImplementation(() => new Promise(() => {})) return view 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 04b25f93e8ad..3081d6fbe08e 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 @@ -560,7 +560,6 @@ describe('useStructuredAgentSessionOutbox', () => { expect(result.current.outbox).toHaveLength(1) // Never sent, and never re-sent on its own: it waits for Retry and holds nothing up. expect(result.current.outbox[0]?.state).toBe('rejected') - expect(result.current.blockedClientMessageId).toBeNull() // Settled, not pending: the refused id never ran, so a Retry is a new operation. const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId const retryId = result.current.outbox[0]!.clientMessageId @@ -778,7 +777,6 @@ describe('useStructuredAgentSessionOutbox', () => { // under the "delivery is unconfirmed" banner. The disposition tests pin its words. await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected')) expect(result.current.outbox[0]?.state).toBe('rejected') - expect(result.current.blockedClientMessageId).toBeNull() // Retry immediately, before the journal subscription can publish the rejected row. act(() => result.current.retry(firstId)) 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 569b707f761e..6af649d6ba6b 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 @@ -2,10 +2,13 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { - admitStructuredAgentSessionOutboxEntry, createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + admitStructuredAgentSessionOutboxEntry, + structuredAgentSessionEntryHeldForRetry +} from '../../../../shared/structured-agent-session-outbox-admission' import { journalAnswersInFlightSend, type StructuredAgentSessionSendDisposition @@ -15,6 +18,7 @@ import { readOutbox, writeOutbox } from './structured-agent-session-outbox-stora import { dispatchStructuredAgentSessionOutboxEntry, readMountedStructuredAgentSessionOutbox, + releaseStructuredAgentSessionRefusalsForNewOwner, requeueInterruptedStructuredAgentSessionDispatches } from './structured-agent-session-outbox-dispatch' import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-session-launch-prompt' @@ -67,7 +71,7 @@ export function useStructuredAgentSessionOutbox(args: { target } = args const { capability: queueCapability, enabled: queueEnabled } = queueDelivery - // What resends, unblocks and drops a send in flight besides a Retry or a new send; see the hook. + // What resends, releases and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) const [outbox, setOutbox] = useState(() => @@ -79,7 +83,8 @@ export function useStructuredAgentSessionOutbox(args: { // "which entry" must never disagree: the journal can settle the tail while the head moves. const inFlightIdRef = useRef(null) const dispatchGenerationRef = useRef(0) - const blockedIdRef = useRef(null) + // The last owner this session's outbox saw; a later one is an owner change, a mount is not. + const lastOwnerRef = useRef(owner.ownerChange) const [error, setError] = useState(null) const [errorSession, setErrorSession] = useState(sessionId) // Render-time reset (react.dev: adjusting state when a prop changes), so the @@ -96,16 +101,28 @@ export function useStructuredAgentSessionOutbox(args: { useLayoutEffect(() => { dispatchGenerationRef.current += 1 inFlightIdRef.current = null - blockedIdRef.current = null }, [owner.ownerChange, owner.targetKey, sessionId]) useEffect(() => { const sessionChanged = outboxSessionRef.current !== sessionId outboxSessionRef.current = sessionId + const lastOwner = lastOwnerRef.current + if (sessionChanged || owner.ownerChange !== null) { + lastOwnerRef.current = owner.ownerChange + } + const ownerMoved = + !sessionChanged && + lastOwner !== null && + owner.ownerChange !== null && + lastOwner !== owner.ownerChange const current = sessionChanged ? readMountedStructuredAgentSessionOutbox(sessionId, owner.fenceRef.current, readOutbox) : outboxRef.current - const next = requeueInterruptedStructuredAgentSessionDispatches(current, owner.fenceRef.current) + const requeued = requeueInterruptedStructuredAgentSessionDispatches( + current, + owner.fenceRef.current + ) + const next = ownerMoved ? releaseStructuredAgentSessionRefusalsForNewOwner(requeued) : requeued if ( sessionChanged || next.some((entry, index) => entry !== current[index]) || @@ -148,11 +165,12 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGenerationRef.current += 1 inFlightIdRef.current = null } - if (blockedIdRef.current !== null && hostOwns.has(blockedIdRef.current)) { - blockedIdRef.current = null - setError(null) - } else if ( - current.some((entry) => entry.state === 'unconfirmed' && hostOwns.has(entry.clientMessageId)) + if ( + current.some( + (entry) => + (entry.state === 'unconfirmed' || structuredAgentSessionEntryHeldForRetry(entry)) && + hostOwns.has(entry.clientMessageId) + ) ) { setError(null) } @@ -164,13 +182,22 @@ export function useStructuredAgentSessionOutbox(args: { // Released here rather than in a `.finally`: the state write below is what re-runs the // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null - blockedIdRef.current = disposition.blockedClientMessageId - setError(disposition.error) - outboxRef.current = disposition.entries - setOutbox(disposition.entries) - writeOutbox(sessionId, disposition.entries) + const returned = disposition.returnedToComposer ?? [] + // Before the write, so a failure between the two repeats the text rather than losing it. + // With no composer to take it, the message stays held with its failure. + const entries = + returned.length === 0 || restoreWithdrawn.toComposer(returned) + ? disposition.entries + : outboxRef.current.map( + (entry) => + returned.find((kept) => kept.clientMessageId === entry.clientMessageId) ?? entry + ) + setError(entries === disposition.entries ? disposition.error : null) + outboxRef.current = entries + setOutbox(entries) + writeOutbox(sessionId, entries) }, - [sessionId] + [restoreWithdrawn, sessionId] ) useEffect(() => { @@ -205,7 +232,7 @@ export function useStructuredAgentSessionOutbox(args: { void launchDispatch.then(mirrorPersisted) return } - const admission = admitStructuredAgentSessionOutboxEntry(outbox, blockedIdRef.current) + const admission = admitStructuredAgentSessionOutboxEntry(outbox) if (admission.state !== 'dispatch' || fence === null || inFlightIdRef.current !== null) { return } @@ -235,7 +262,6 @@ export function useStructuredAgentSessionOutbox(args: { dispatchGeneration, dispatchGenerationRef, inFlightIdRef, - blockedIdRef, outboxRef, setOutbox, setError, @@ -289,7 +315,6 @@ export function useStructuredAgentSessionOutbox(args: { submissions, queuedMessageIds, outboxRef, - blockedIdRef, inFlightIdRef, dispatchGenerationRef, setOutbox, @@ -297,10 +322,6 @@ export function useStructuredAgentSessionOutbox(args: { }) const retry = (clientMessageId: string): void => { - // Another message's Retry must not send the one the queue is held on. - if (blockedIdRef.current === clientMessageId) { - blockedIdRef.current = null - } setError(null) retryStructuredAgentSessionOutboxEntry({ clientMessageId, @@ -315,7 +336,6 @@ export function useStructuredAgentSessionOutbox(args: { return { outbox, error, - blockedClientMessageId: blockedIdRef.current, send, retry, withdrawUnsent diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx index 149fe7b22f03..3b782209a5e4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-cancel.test.tsx @@ -32,7 +32,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: mocks.operationId, useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx index 91769bde5313..b5a18fbfb12a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-prompt-state.test.tsx @@ -55,7 +55,6 @@ vi.mock('./use-structured-agent-session-read', () => ({ vi.mock('./use-structured-agent-session-outbox', () => ({ useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index 633faf0f4226..2d6800695ce8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -43,7 +43,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ mocks.outbox(args) return { outbox: [], - blockedClientMessageId: null, error: null, send: mocks.send, retry: mocks.retry diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx index 337b22b4f5ec..a8ffff83ce4c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-question-answers.test.tsx @@ -34,7 +34,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: mocks.operationId, useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx index 5d517bf57bb9..c54f0f44d5f2 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx @@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({ let items: AgentJournalRenderItem[] = [] let submissions: AgentJournalSubmission[] = [] let outbox: StructuredAgentSessionOutboxEntry[] = [] -let blockedClientMessageId: string | null = null let fence = 3 vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -40,7 +39,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: () => `operation-${++mocks.operations}`, useStructuredAgentSessionOutbox: () => ({ outbox, - blockedClientMessageId, error: null, send: vi.fn(), retry: vi.fn(), @@ -139,7 +137,6 @@ beforeEach(() => { items = [] submissions = [] outbox = [] - blockedClientMessageId = null fence = 3 }) @@ -388,14 +385,12 @@ describe('Stop against a host that stops the conversation', () => { }) it('is hidden with only a message that waits on its Retry', () => { - // A send that failed holds the queue until the user retries it. - outbox = [entry('queued')] - blockedClientMessageId = 'client-1' + // A send that failed waits, with its saved failure, until the user retries it. + outbox = [{ ...entry('queued'), lastFailure: { kind: 'failed' } }] expect(render().result.current.canStop).toBe(false) // A send the host restarted under is parked for the user, and the chat reads idle. outbox = [{ ...entry('unconfirmed'), retryAfterUnknownSubmittedAt: -1 }] - blockedClientMessageId = null submissions = [ submission({ dispatchState: 'unknown', diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index 4815c0a944ea..441b279868a5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -34,7 +34,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: () => 'operation-1', useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index cd4de967f5d8..6d5a4f675b4e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -54,7 +54,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ mocks.outboxArgs.push(args) return { outbox: outboxEntries, - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn(), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 719fa29be616..850407511d2d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -45,7 +45,6 @@ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: mocks.operationId, useStructuredAgentSessionOutbox: () => ({ outbox: [], - blockedClientMessageId: null, error: null, send: vi.fn(), retry: vi.fn() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index e4d18eaed2ee..29abbc676770 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -152,30 +152,16 @@ export function useStructuredAgentSession(args: { transportState.turnId !== null || (stopsConversation && (transportState.isWorking || - hasUnsentStructuredAgentSessionOutboxEntry( - outbox, - transportState.submissions, - outboxController.blockedClientMessageId - ))) + hasUnsentStructuredAgentSessionOutboxEntry(outbox, transportState.submissions))) // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( () => - outboxOutsideQueuedCards( - outbox, - queuedMessageIds, - isWorking, - outboxController.blockedClientMessageId, - { capability: queueCapability, enabled: queueFollowUps } - ), - [ - isWorking, - outbox, - outboxController.blockedClientMessageId, - queueCapability, - queueFollowUps, - queuedMessageIds - ] + outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, { + capability: queueCapability, + enabled: queueFollowUps + }), + [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] ) const messages = useStructuredAgentSessionMessages( transportState.journalItems, @@ -229,7 +215,6 @@ export function useStructuredAgentSession(args: { outbox, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, - blockedClientMessageId: outboxController.blockedClientMessageId, // A message typed during a command queues behind it on the host. send: (...input: Parameters) => // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts index 0df8fdbea31a..89789e012495 100644 --- a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts @@ -20,7 +20,7 @@ export function useStructuredAgentSessionHostAcceptsSend(target: RuntimeClientTa /** * When an outbox treats its owner as changed: resending a send in flight under the same id, - * dropping that send's answer, and unblocking a refused head. An older host restarts the agent + * dropping that send's answer, and releasing a refused message. An older host restarts the agent * inside the send and refuses it, unrecorded, when that fails, so a new fence is its only word that * another try may land. A host that accepts first records every send before it starts anything, * so a moved fence means nothing there, and only a Retry or a new send goes out. diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts index 30a9a177f8e5..79020e5695f0 100644 --- a/src/shared/agent-session-refusal-retry.test.ts +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -3,10 +3,13 @@ import type { AgentSessionOwnerVerdict } from './agent-session-wire' import { agentSessionOwnerVerdictAllowsFreshOperationId } from './agent-session-refusal-retry' import { parseAgentSessionWriteFailure } from './agent-session-write-failure' import { - admitStructuredAgentSessionOutboxEntry, createStructuredAgentSessionOutboxEntry, requeueStructuredAgentSessionSendRefusal } from './structured-agent-session-outbox' +import { + admitStructuredAgentSessionOutboxEntry, + structuredAgentSessionEntryHeldForRetry +} from './structured-agent-session-outbox-admission' import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' const entry = createStructuredAgentSessionOutboxEntry({ @@ -49,7 +52,7 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) }) - it('keeps later sends behind the message after an exited refusal', () => { + it('holds the message for its Retry after an exited refusal, and sends later ones past it', () => { const later = createStructuredAgentSessionOutboxEntry({ clientMessageId: 'message-3', sessionId: 'session-1', @@ -60,7 +63,6 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry, later], entry, - blockedClientMessageId: null, result: { ok: false, refusal: { @@ -71,13 +73,12 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( }, createOperationId: () => 'message-2' }) - expect(disposition.blockedClientMessageId).toBe('message-2') - expect( - admitStructuredAgentSessionOutboxEntry( - disposition.entries, - disposition.blockedClientMessageId - ) - ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'message-2' } }) + expect(disposition.entries[0]).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) + expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true) + expect(admitStructuredAgentSessionOutboxEntry(disposition.entries)).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'message-3' } + }) }) it.each(['unverifiable', 'live', undefined])( diff --git a/src/shared/structured-agent-session-draft-hand-off.test.ts b/src/shared/structured-agent-session-draft-hand-off.test.ts index 4a493d9e6223..6e0cbc7e644f 100644 --- a/src/shared/structured-agent-session-draft-hand-off.test.ts +++ b/src/shared/structured-agent-session-draft-hand-off.test.ts @@ -51,16 +51,13 @@ describe('a queued draft handed off under a fresh submission id', () => { it('answers the send in flight and leaves nothing a Stop would withdraw', () => { expect(journalAnswersInFlightSend([handOff('pending')], 'draft')).toBe(true) expect(journalAnswersInFlightSend([handOff('pending')], null)).toBe(false) - expect(hasUnsentStructuredAgentSessionOutboxEntry([entry], [handOff('pending')], null)).toBe( - false - ) + expect(hasUnsentStructuredAgentSessionOutboxEntry([entry], [handOff('pending')])).toBe(false) }) it('settles a replayed send answered with the hand-off, with no notice', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, createOperationId: () => 'rotated', result: { ok: true, @@ -70,6 +67,6 @@ describe('a queued draft handed off under a fresh submission id', () => { value: { clientMessageId: 'hand-off', submission: handOff('rejected') } } }) - expect(disposition).toEqual({ entries: [], error: null, blockedClientMessageId: null }) + expect(disposition).toEqual({ entries: [], error: null }) }) }) diff --git a/src/shared/structured-agent-session-outbox-admission.ts b/src/shared/structured-agent-session-outbox-admission.ts new file mode 100644 index 000000000000..3d7e95889342 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-admission.ts @@ -0,0 +1,45 @@ +// Which outbox entry goes out next, and which ones wait for the user. + +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' + +/** A send the user was told did not go through, with a Retry: only that Retry sends it again. Read + * from the saved failure, so a relaunch holds it exactly as the refusal did. */ +export function structuredAgentSessionEntryHeldForRetry( + entry: StructuredAgentSessionOutboxEntry +): boolean { + return entry.state === 'queued' && entry.lastFailure !== undefined +} + +export type StructuredAgentSessionOutboxAdmission = + | { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry } + | { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry } + | { state: 'idle'; entry: null } + +/** + * What the queue does next. The drain and the Retry affordance both read it, so neither can + * disagree with the other about which entry is holding the queue. + * + * A `dispatching` entry is not a barrier: the host appended its journal row inside the + * per-session serialize chain before dispatching, so nothing behind it can overtake it, and + * waiting for its echo costs delivery of everything queued behind it. An `unconfirmed` entry + * is a barrier — sending past it would reorder around a message that may yet land. One the + * user was told did not go through is not: it lands only by its own Retry, so what the user + * sends after it goes out as they send it. + */ +export function admitStructuredAgentSessionOutboxEntry( + entries: readonly StructuredAgentSessionOutboxEntry[] +): StructuredAgentSessionOutboxAdmission { + for (const entry of entries) { + if (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) { + continue + } + if (entry.state === 'unconfirmed') { + return { state: 'blocked', entry } + } + // A queue send a Stop outlived goes again only on the user's Retry. + if (entry.state === 'queued') { + return { state: entry.outlivedStop === true ? 'blocked' : 'dispatch', entry } + } + } + return { state: 'idle', entry: null } +} diff --git a/src/shared/structured-agent-session-outbox-retry-hold.test.ts b/src/shared/structured-agent-session-outbox-retry-hold.test.ts new file mode 100644 index 000000000000..bac99301cbb8 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { + admitStructuredAgentSessionOutboxEntry, + structuredAgentSessionEntryHeldForRetry +} from './structured-agent-session-outbox-admission' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +const REFUSED = { kind: 'refused', code: 'agent_session_journal_unreadable' } as const + +describe('a message held for its Retry', () => { + it('is one the user was told did not go through, and still queued', () => { + expect(structuredAgentSessionEntryHeldForRetry(entry('a', { lastFailure: REFUSED }))).toBe(true) + expect( + structuredAgentSessionEntryHeldForRetry(entry('a', { lastFailure: { kind: 'failed' } })) + ).toBe(true) + expect(structuredAgentSessionEntryHeldForRetry(entry('a'))).toBe(false) + expect( + structuredAgentSessionEntryHeldForRetry( + entry('a', { state: 'rejected', lastFailure: { kind: 'rejected', reason: null } }) + ) + ).toBe(false) + }) + + it('is passed over by the drain, which still stops on a message in doubt', () => { + const held = entry('held', { lastFailure: REFUSED }) + expect(admitStructuredAgentSessionOutboxEntry([held])).toEqual({ state: 'idle', entry: null }) + expect(admitStructuredAgentSessionOutboxEntry([held, entry('next')])).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'next' } + }) + expect( + admitStructuredAgentSessionOutboxEntry([ + held, + entry('doubt', { state: 'unconfirmed' }), + entry('next') + ]) + ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'doubt' } }) + }) + + it('is released once the host shows it has the message after all', () => { + const submission: AgentJournalSubmission = { + clientMessageId: 'held', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + const [landed] = reconcileStructuredAgentSessionOutbox( + [entry('held', { lastFailure: REFUSED })], + [submission] + ) + expect(landed?.state).toBe('dispatching') + expect(landed?.lastFailure).toBeUndefined() + }) +}) diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index a79dd8058dd1..5606ae0c9ceb 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -1,32 +1,28 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' -/** Whether only the user's Retry sends this entry again: a refused one, the one the drain stopped - * on, one a Stop outlived, or one in doubt the unconfirmed probe leaves alone. `NativeChatDeliveryRetry` offers it. */ -function awaitsStructuredAgentSessionRetry( - entry: StructuredAgentSessionOutboxEntry, - blockedClientMessageId: string | null -): boolean { +/** Whether only the user's Retry sends this entry again: a rejected one, one whose send failed or + * was refused, one a Stop outlived, or one in doubt the unconfirmed probe leaves alone. + * `NativeChatDeliveryRetry` offers it. */ +function awaitsStructuredAgentSessionRetry(entry: StructuredAgentSessionOutboxEntry): boolean { return ( entry.state === 'rejected' || - entry.clientMessageId === blockedClientMessageId || + structuredAgentSessionEntryHeldForRetry(entry) || entry.outlivedStop === true || (entry.state === 'unconfirmed' && entry.retryAfterUnknownSubmittedAt !== null) ) } function unsentStructuredAgentSessionOutboxEntry( - submissions: readonly AgentJournalSubmission[], - blockedClientMessageId: string | null + submissions: readonly AgentJournalSubmission[] ): (entry: StructuredAgentSessionOutboxEntry) => boolean { const held = handedOffQueuedMessageIds(submissions) for (const submission of submissions) { held.add(submission.clientMessageId) } - return (entry) => - !held.has(entry.clientMessageId) && - !awaitsStructuredAgentSessionRetry(entry, blockedClientMessageId) + return (entry) => !held.has(entry.clientMessageId) && !awaitsStructuredAgentSessionRetry(entry) } /** A queue send that has gone out at least once and was not refused, in whatever state it now @@ -63,10 +59,9 @@ function markedOutlivingStop( export function withdrawUnsentStructuredAgentSessionOutboxEntries( entries: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], - blockedClientMessageId: string | null, inFlightClientMessageId: string | null ): StructuredAgentSessionOutboxEntry[] { - const unsent = unsentStructuredAgentSessionOutboxEntry(submissions, blockedClientMessageId) + const unsent = unsentStructuredAgentSessionOutboxEntry(submissions) return entries .filter( (entry) => @@ -80,8 +75,7 @@ export function withdrawUnsentStructuredAgentSessionOutboxEntries( /** Whether a Stop has something here to withdraw: a message that would still go out on its own. */ export function hasUnsentStructuredAgentSessionOutboxEntry( entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[], - blockedClientMessageId: string | null + submissions: readonly AgentJournalSubmission[] ): boolean { - return entries.some(unsentStructuredAgentSessionOutboxEntry(submissions, blockedClientMessageId)) + return entries.some(unsentStructuredAgentSessionOutboxEntry(submissions)) } diff --git a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts index 1416430beb09..958979d25c1d 100644 --- a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts +++ b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from 'vitest' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { - admitStructuredAgentSessionOutboxEntry, createStructuredAgentSessionOutboxEntry, parseStructuredAgentSessionOutboxEntry, stageStructuredAgentSessionOutboxEntryForSend, @@ -14,6 +13,7 @@ import { type StructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxState } from './structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' import { structuredAgentSessionEntryAttempt } from './structured-agent-session-outbox-delivery' import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' import { withdrawUnsentStructuredAgentSessionOutboxEntries } from './structured-agent-session-outbox-stop-withdrawal' @@ -104,7 +104,6 @@ describe('outbox queue delivery', () => { at('sent-plain', 'dispatching', null) ], [], - null, null ) // Its state is left to its answer: only the mark holds it back. @@ -114,7 +113,7 @@ describe('outbox queue delivery', () => { ['probed', 'queued', true] ]) // The drain never admits the marked one it would otherwise send. - expect(admitStructuredAgentSessionOutboxEntry(next.slice(2), null)).toEqual({ + expect(admitStructuredAgentSessionOutboxEntry(next.slice(2))).toEqual({ state: 'blocked', entry: next[2] }) @@ -138,16 +137,10 @@ describe('outbox queue delivery', () => { disposeStructuredAgentSessionSendResult({ entries, entry: attempt.wire, - blockedClientMessageId: null, result: refusal, createOperationId: () => operations.shift() ?? 'spent' }) - const stopped = withdrawUnsentStructuredAgentSessionOutboxEntries( - [staged], - [], - null, - 'client-1' - ) + const stopped = withdrawUnsentStructuredAgentSessionOutboxEntries([staged], [], 'client-1') const withStop = answer(stopped) const withoutStop = answer([staged]) expect( @@ -156,6 +149,5 @@ describe('outbox queue delivery', () => { expect( withoutStop.entries.map((candidate) => [candidate.clientMessageId, candidate.state]) ).toEqual([['rotated-2', 'rejected']]) - expect(withStop.blockedClientMessageId).toBeNull() }) }) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 3c2b6834114c..036d1af9d560 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -41,7 +41,8 @@ export type StructuredAgentSessionOutboxEntry = { * what that request carries. */ sentDelivery?: 'queue-if-active' | null /** Why the last attempt did not go through. Lives on the message so it goes when the message - * is sent again or delivered, instead of outliving it as a separate error. */ + * is sent again or delivered, instead of outliving it as a separate error. On a `queued` entry + * it is also the hold (structured-agent-session-outbox-admission). */ lastFailure?: StructuredAgentSessionAttemptFailure } @@ -168,7 +169,7 @@ export function requeueStructuredAgentSessionSendRefusal( ): StructuredAgentSessionOutboxEntry { const refusalSettled = agentSessionRefusalOperationState(refusal.code) === 'settled-rejected' // An exited owner runs nothing under the old id, so a new one can't collide; the message still - // holds the head, since nothing recorded it. + // waits for its Retry, since nothing recorded it. const ownerExited = refusal.code === 'agent_session_ownership_unknown' && agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) @@ -181,8 +182,8 @@ export function requeueStructuredAgentSessionSendRefusal( return { ...entry, state: 'queued' } } // Only here may the id rotate: an earlier attempt under this id, or one whose delivery was in - // doubt, may have landed, so those stay queued behind the block. Only a settled refusal proves - // the message never landed and so releases the queue. + // doubt, may have landed, so those keep it. Only a settled refusal proves the message never + // landed. return { ...entry, clientMessageId: createOperationId(), @@ -209,7 +210,12 @@ export function reconcileStructuredAgentSessionOutbox( return [] } if (submission?.dispatchState === 'pending') { - return entry.state === 'dispatching' ? [entry] : [{ ...entry, state: 'dispatching' as const }] + if (entry.state === 'dispatching') { + return [entry] + } + // The host has it, so no failure of an earlier attempt describes it now. + const { lastFailure: _landed, ...landed } = entry + return [{ ...landed, state: 'dispatching' as const }] } // Accepted, then not delivered — the agent never started, or its start was refused. The text // and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a @@ -237,41 +243,6 @@ export function reconcileStructuredAgentSessionOutbox( }) } -export type StructuredAgentSessionOutboxAdmission = - | { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry } - | { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry } - | { state: 'idle'; entry: null } - -/** - * What the queue does next. The drain and the Retry affordance both read it, so neither can - * disagree with the other about which entry is holding the queue. - * - * A `dispatching` entry is not a barrier: the host appended its journal row inside the - * per-session serialize chain before dispatching, so nothing behind it can overtake it, and - * waiting for its echo costs delivery of everything queued behind it. An `unconfirmed` entry, - * or one the user must act on, is a barrier — sending past either would reorder around a - * message that may yet land. - */ -export function admitStructuredAgentSessionOutboxEntry( - entries: readonly StructuredAgentSessionOutboxEntry[], - blockedClientMessageId: string | null -): StructuredAgentSessionOutboxAdmission { - for (const entry of entries) { - // It can no longer land, so nothing it could be reordered around; it waits for Retry. - if (entry.state === 'rejected') { - continue - } - if (entry.state === 'unconfirmed' || entry.clientMessageId === blockedClientMessageId) { - return { state: 'blocked', entry } - } - // A queue send a Stop outlived goes again only on the user's Retry. - if (entry.state === 'queued') { - return { state: entry.outlivedStop === true ? 'blocked' : 'dispatch', entry } - } - } - return { state: 'idle', entry: null } -} - export function parseStructuredAgentSessionOutboxEntry( value: unknown, sessionId: string diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index c905584fd80f..928fc2f45f73 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -15,6 +15,7 @@ import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice' import { disposeStructuredAgentSessionSendFailure, disposeStructuredAgentSessionSendResult, + STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE, structuredAgentSessionAttemptFailureParts } from './structured-agent-session-send-disposition' import { @@ -22,6 +23,7 @@ import { reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({ clientMessageId: 'client-1', @@ -59,7 +61,6 @@ function notice(reason: string | null, rejection?: AgentSessionFailureFact): str const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: rejectedWith(reason, rejection ? { rejection } : {}), createOperationId: () => 'unused' }) @@ -76,7 +77,6 @@ describe('a queued draft answer', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: { ok: true, replayed: false, @@ -97,7 +97,6 @@ describe('a queued draft answer', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: { ok: true, replayed: true, @@ -158,15 +157,10 @@ describe('what a rejection shows the user', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: rejectedWith(DISPATCH_REJECTED_CANCELLED, { rejection, replayed }), createOperationId: () => 'unused' }) - expect(disposition).toEqual({ - entries: [], - error: null, - blockedClientMessageId: null - }) + expect(disposition).toEqual({ entries: [], error: null }) } }) @@ -180,7 +174,6 @@ describe('what a rejection shows the user', () => { disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result, createOperationId: () => 'unused' }).error @@ -263,7 +256,6 @@ describe('what a refusal shows the user', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: { ok: false, refusal: { @@ -275,7 +267,7 @@ describe('what a refusal shows the user', () => { }) expect(disposition.error).toBeNull() - expect(disposition.blockedClientMessageId).toBe(entry.clientMessageId) + expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true) expect(disposition.entries).toMatchObject([ { clientMessageId: entry.clientMessageId, @@ -294,7 +286,6 @@ describe('what a refusal shows the user', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: { ok: false, refusal: { @@ -323,7 +314,6 @@ describe('what a refusal shows the user', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result: rejectedWith('An image on this message is empty, so the message was not sent.', { rejection: { kind: 'attachmentInvalid', @@ -345,7 +335,6 @@ describe('what a refusal shows the user', () => { const disposition = disposeStructuredAgentSessionSendFailure({ entries: [entry], entry, - blockedClientMessageId: null, cause: new Error('socket hang up: ECONNRESET 10.0.0.2:443'), isDeliveryUnknown: () => false }) @@ -367,7 +356,6 @@ describe('what a refusal shows the user', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [refused], entry: refused, - blockedClientMessageId: null, result, createOperationId: () => 'unused' }) @@ -381,25 +369,44 @@ describe('ambiguous operation refusals', () => { it.each([ { ...entry, state: 'unconfirmed' as const, lastAttemptAt: 10 }, { ...entry, state: 'queued' as const, lastAttemptAt: 10, retryAfterUnknownSubmittedAt: 10 } - ])('never rotates $state operation after its host tombstone expires', (ambiguous) => { + ])( + 'never rotates $state operation after its host tombstone expires; gives its text back', + (ambiguous) => { + const disposition = disposeStructuredAgentSessionSendResult({ + entries: [ambiguous], + entry: ambiguous, + result: { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + message: 'Operation expired.' + } + }, + createOperationId: () => 'fresh-id' + }) + + // No id can deliver it now: never a fresh one, and the kept one is refused for good. + expect(disposition.entries).toEqual([]) + expect(disposition.returnedToComposer).toMatchObject([ + { clientMessageId: entry.clientMessageId, state: 'queued' } + ]) + expect(disposition.error).toBe(STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE) + } + ) + + it('rotates a first attempt the host calls expired, which nothing can have delivered', () => { const disposition = disposeStructuredAgentSessionSendResult({ - entries: [ambiguous], - entry: ambiguous, - blockedClientMessageId: null, + entries: [entry], + entry, result: { ok: false, - refusal: { - code: 'agent_session_operation_expired', - message: 'Operation expired.' - } + refusal: { code: 'agent_session_operation_expired', message: 'Operation expired.' } }, createOperationId: () => 'fresh-id' }) - expect(disposition.entries).toMatchObject([ - { clientMessageId: entry.clientMessageId, state: 'queued' } - ]) - expect(disposition.blockedClientMessageId).toBe(entry.clientMessageId) + expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }]) + expect(disposition.returnedToComposer).toBeUndefined() }) it('parks a recovered missing submission without polling forever', () => { @@ -417,7 +424,6 @@ describe('ambiguous operation refusals', () => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [entry], entry, - blockedClientMessageId: null, result, createOperationId: () => 'unused' }) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 885af6581adc..4d0e72f8719a 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -34,15 +34,16 @@ export type StructuredAgentSessionSendDisposition = { entries: StructuredAgentSessionOutboxEntry[] /** Only for an outcome with no entry left to carry it; a kept entry holds its own failure. */ error: string | null - /** 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 + /** Messages no send can deliver any more, whose text goes back to the composer. */ + returnedToComposer?: StructuredAgentSessionOutboxEntry[] } +export const STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE = + "This message can no longer be sent, so it's back in the message box. Check the chat before sending it again." + type SendDispositionInput = { entries: readonly StructuredAgentSessionOutboxEntry[] entry: StructuredAgentSessionOutboxEntry - blockedClientMessageId: string | null } function replaceEntryState( @@ -200,10 +201,8 @@ export function disposeStructuredAgentSessionSendRefusal( createOperationId: () => string } ): StructuredAgentSessionSendDisposition { - const refusedIndex = input.entries.findIndex( - (candidate) => candidate.clientMessageId === input.entry.clientMessageId - ) - const entries = input.entries.map((candidate) => + // The refusal saved on a message it keeps `queued` is what holds it for the user's Retry. + const entries: StructuredAgentSessionOutboxEntry[] = input.entries.map((candidate) => candidate.clientMessageId === input.entry.clientMessageId ? withLastFailure( requeueStructuredAgentSessionSendRefusal( @@ -216,18 +215,17 @@ export function disposeStructuredAgentSessionSendRefusal( ) : candidate ) - const refused = entries[refusedIndex] - return { - entries, - error: null, - // Read back by index rather than from the input: a refusal can rotate the id, and the - // refused entry is not always the head now that an admitted one no longer holds the queue. - // A rejected one holds nothing: it can no longer land, and it keeps its own Retry. - blockedClientMessageId: - !refused || refused.state === 'rejected' - ? input.blockedClientMessageId - : refused.clientMessageId + // Expired is final for an id, and a kept id is one an earlier attempt may have delivered, so no + // Retry could go through and a new id could send it twice: the user decides, from the chat. + const kept = entries.find((entry) => entry.clientMessageId === input.entry.clientMessageId) + if (kept && input.refusal.code === 'agent_session_operation_expired') { + return { + entries: dropEntry(input), + error: STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE, + returnedToComposer: [kept] + } } + return { entries, error: null } } export function disposeStructuredAgentSessionSendResult( @@ -249,8 +247,7 @@ export function disposeStructuredAgentSessionSendResult( // and the draft card — not this queue — carries any later refusal. return { entries: dropEntry(input), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } const submission = result.value.submission @@ -259,22 +256,19 @@ export function disposeStructuredAgentSessionSendResult( if (submission.queuedMessageId === input.entry.clientMessageId) { return { entries: dropEntry(input), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } if (refusedRedelivery(input.entry, submission)) { return { entries: dropEntry(input), - error: 'Message delivery is unconfirmed and Orca will not send it again', - blockedClientMessageId: input.blockedClientMessageId + error: 'Message delivery is unconfirmed and Orca will not send it again' } } if (submission.dispatchState === 'accepted') { return { entries: dropEntry(input), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } // A Stop's withdrawal failed nothing, first reply or replay: the entry leaves as the reconcile @@ -285,8 +279,7 @@ export function disposeStructuredAgentSessionSendResult( ) { return { entries: dropEntry(input), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } if (submission.dispatchState === 'rejected') { @@ -296,8 +289,7 @@ export function disposeStructuredAgentSessionSendResult( 'rejected', structuredAgentSessionRejectedFailure(submission) ), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } if (submission.dispatchState === 'unknown' && submission.recovered) { @@ -307,8 +299,7 @@ export function disposeStructuredAgentSessionSendResult( ? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 } : candidate ), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } // `pending` is the host saying the message was written and is awaiting the @@ -322,8 +313,7 @@ export function disposeStructuredAgentSessionSendResult( input, submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' ), - error: null, - blockedClientMessageId: input.blockedClientMessageId + error: null } } @@ -336,13 +326,11 @@ export function disposeStructuredAgentSessionSendFailure( const failure = classifyStructuredAgentSessionSendFailure(input.cause, input.isDeliveryUnknown) const deliveryUnknown = failure === 'delivery-unknown' return { - // An unconfirmed entry's Retry row already says delivery is unconfirmed. + // An unconfirmed entry's Retry row already says delivery is unconfirmed; the saved failure + // holds the other for its Retry. entries: deliveryUnknown ? replaceEntryState(input, 'unconfirmed') : replaceEntryState(input, 'queued', { kind: 'failed' }), - error: null, - blockedClientMessageId: deliveryUnknown - ? input.blockedClientMessageId - : input.entry.clientMessageId + error: null } } From f28478fd72e3eb172523aef8ba4ccb9339636db5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:13:55 -0700 Subject: [PATCH 2/6] fix(native-chat): keep refused messages as rows until Retry, and only release them for an older host's new owner - A message the host refuses as expired under an id it kept stays a saved row reading "Orca couldn't confirm what happened. Check the chat.", and its Retry sends it under a new id. It no longer moves into the message box, where an automatic resend after a relaunch could put text the user never asked for, held only in memory. - An owner change releases a refused message only on a host known to predate accepted sends, and only for the refusals such a host gives while it restarts the chat's agent. Those rows say Orca will send it again when the agent restarts, beside their Retry. A host whose capability check has not answered, or failed, no longer releases anything. - Every failed message ahead of the one the queue stopped on keeps its Retry, since that Retry sends it at once. - A journal row saying the host cannot tell whether a message landed, and the unconfirmed probe's resend, replace an earlier attempt's saved failure, so the message is probed rather than held. - The drain stages from the hook's own outbox, so a hold kept only in memory after a failed save survives the next send; the hold is written once more after that failed save. --- .../NativeChatStructuredSession.tsx | 12 +- .../agent-session-write-notice-text.ts | 2 + ...red-agent-session-delivery-notices.test.ts | 86 +++++++- ...ructured-agent-session-delivery-notices.ts | 47 +++-- ...tructured-agent-session-outbox-dispatch.ts | 16 +- .../structured-agent-session-outbox-retry.ts | 15 +- ...agent-session-withdrawn-message-restore.ts | 16 +- ...gent-session-outbox-owner-release.test.tsx | 161 ++++++++++++++ ...gent-session-outbox-relaunch-hold.test.tsx | 196 ++++++++++++++++-- ...-agent-session-outbox-unconfirmed-probe.ts | 11 +- .../use-structured-agent-session-outbox.ts | 65 +++--- .../use-structured-agent-session.ts | 1 + .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...-session-accepted-send-capability.test.tsx | 30 ++- ...-agent-session-accepted-send-capability.ts | 34 ++- src/shared/agent-session-write-notice-copy.ts | 1 + ...ructured-agent-session-outbox-admission.ts | 22 ++ ...ed-agent-session-outbox-retry-hold.test.ts | 24 +++ src/shared/structured-agent-session-outbox.ts | 17 +- ...red-agent-session-send-disposition.test.ts | 18 +- ...ructured-agent-session-send-disposition.ts | 15 -- 27 files changed, 653 insertions(+), 143 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index cef7142094f5..4fac23008ba3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -134,9 +134,17 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures + startFailures, + controller.resendsOnNewOwner ), - [controller.outbox, agentLabel, retryDelivery, rejectionRows, startFailures] + [ + controller.outbox, + agentLabel, + retryDelivery, + rejectionRows, + startFailures, + controller.resendsOnNewOwner + ] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index e584429ddfbf..1bbb90244d3d 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -36,6 +36,8 @@ const SENTENCES: Record string> = { notDoneGoal: () => translate('components.native-chat.writeNotice.notDoneGoal', COPY.notDoneGoal), restartFailed: () => translate('components.native-chat.writeNotice.restartFailed', COPY.restartFailed), + resendsOnRestart: () => + translate('components.native-chat.writeNotice.resendsOnRestart', COPY.resendsOnRestart), capacity: () => translate('components.native-chat.writeNotice.capacity', COPY.capacity), outcomeUnknown: () => translate('components.native-chat.writeNotice.outcomeUnknown', COPY.outcomeUnknown), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 286b1e54acce..a3fc0814b7c4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -167,11 +167,11 @@ describe('the notice on each message that did not go through', () => { }) // Its Retry would put it back in the queue to wait unseen behind the stopped message. - it('keeps a rejected message its words but not its Retry while the queue is stopped', () => { + it('keeps a rejected message behind the stopped one its words but not its Retry', () => { const retry = vi.fn() for (const outbox of [ [entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], - [entry('rejected', { state: 'rejected' }), entry('held', { outlivedStop: true })] + [entry('held', { outlivedStop: true }), entry('rejected', { state: 'rejected' })] ]) { const notices = structuredAgentSessionDeliveryNotices(outbox, 'Claude', retry, [], []) expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ @@ -180,6 +180,36 @@ describe('the notice on each message that did not go through', () => { } }) + // Ahead of the stopped message, its Retry sends it at once, so the row offers it. + it.each([ + ['in doubt', { state: 'unconfirmed' as const }], + ['outlived by a Stop', { outlivedStop: true as const }] + ])('gives a failed message ahead of one %s its Retry', (_label, patch) => { + const retry = vi.fn() + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('refused', { + lastAttemptAt: 1, + lastFailure: { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + }), + entry('rejected', { state: 'rejected' }), + entry('stuck', { lastAttemptAt: 2, ...patch }) + ], + 'Claude', + retry, + [], + [] + ) + for (const id of ['refused', 'rejected', 'stuck']) { + notices.get(agentJournalSubmissionKey(id))?.onRetry?.() + } + expect(retry.mock.calls).toEqual([['refused'], ['rejected'], ['stuck']]) + }) + // Beside its own Retry the resend step is the button; without one the words keep it. it('leaves out sending again only where the message has its own Retry', () => { const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry => @@ -195,7 +225,7 @@ describe('the notice on each message that did not go through', () => { [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.', [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.' }) - expect(texts([startFailed('rejected'), entry('held', { outlivedStop: true })])).toMatchObject({ + expect(texts([entry('held', { outlivedStop: true }), startFailed('rejected')])).toMatchObject({ [agentJournalSubmissionKey('rejected')]: 'Claude stopped before it finished starting. Send your message to try again.' }) @@ -365,6 +395,56 @@ describe('the notice on each message that did not go through', () => { }) }) + // An older host's next agent owner sends it again; the Retry stays in case none comes. + it('says a message an older host refused for want of an owner goes again on its restart', () => { + const outbox = [ + entry('owner', { + lastAttemptAt: 1, + lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' } + }), + entry('journal', { + lastAttemptAt: 1, + lastFailure: { kind: 'refused', code: 'agent_session_journal_unreadable' } + }) + ] + const retry = vi.fn() + const older = structuredAgentSessionDeliveryNotices(outbox, 'Claude', retry, [], [], true) + expect(older.get(agentJournalSubmissionKey('owner'))?.text).toBe( + 'Your message was not sent. Orca will send it again when the agent restarts.' + ) + expect(older.get(agentJournalSubmissionKey('owner'))?.onRetry).toBeDefined() + expect(older.get(agentJournalSubmissionKey('journal'))?.text).toBe( + "Orca couldn't read this chat's saved history. Your message was not sent." + ) + expect(texts(outbox)[agentJournalSubmissionKey('owner')]).toBe('Your message was not sent.') + }) + + // An earlier attempt under the id may have landed, so the row never says it was not sent. + it('words a kept message whose id expired as an outcome Orca cannot confirm', () => { + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('expired', { + lastAttemptAt: 1, + lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' } + }), + entry('fresh', { + state: 'rejected', + lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' } + }) + ], + 'Claude', + vi.fn(), + [], + [] + ) + expect(notices.get(agentJournalSubmissionKey('expired'))).toMatchObject({ + text: "Orca couldn't confirm what happened. Check the chat." + }) + expect(notices.get(agentJournalSubmissionKey('expired'))?.onRetry).toBeDefined() + // A first attempt's id was replaced when it was refused, so nothing can have landed. + expect(notices.get(agentJournalSubmissionKey('fresh'))?.text).toBe('Your message was not sent.') + }) + it('says nothing on a message that is only waiting its turn or on its way', () => { expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index e37e90a8534f..6e770bdb5822 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -2,9 +2,10 @@ // // Derived from the outbox on every render and never stored: each failed or held message carries // its own typed failure, so each row words its own reason. Read through the drain's own rule: while -// the queue is stopped, only the message it stopped on has a Retry; another's would wait unseen -// behind it. One waiting behind says nothing; a rejected or refused message holds nothing up, so it -// keeps its words and gets its Retry once the queue moves. +// the queue is stopped, the message it stopped on and any failed one ahead of it have a Retry, as +// each would go out at once; one behind it would wait unseen. One waiting behind says nothing; a +// rejected or refused message holds nothing up, so it keeps its words and gets its Retry once the +// queue moves. // // A message the host recorded and then rejected is worded from the journal's own fact, found by id; // the message keeps only a smaller copy, read when its submission is not loaded. A rejection that @@ -23,10 +24,14 @@ import type { } from '../../../../shared/agent-session-journal-types' import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session-refusal-notice' import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + structuredAgentSessionEntryIdExpired, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' import { admitStructuredAgentSessionOutboxEntry, - structuredAgentSessionEntryHeldForRetry + structuredAgentSessionEntryHeldForRetry, + structuredAgentSessionEntryWaitsForNewOwner } from '../../../../shared/structured-agent-session-outbox-admission' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' @@ -84,7 +89,8 @@ function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[] + startFailures: readonly AgentSessionFailureFact[], + resendsOnNewOwner: boolean ): string { // A send attempted before a Stop and then interrupted may already be with the host. const attemptedAcrossStop = @@ -101,18 +107,25 @@ function deliveryNoticeText( 'Message was not sent.' ) } + // An earlier attempt under the id the host forgot may already be in the chat. + if (structuredAgentSessionEntryIdExpired(entry)) { + return agentSessionWriteNoticeText(['outcomeUnknown']) + } if ( entry.state === 'rejected' && agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures) ) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } + const parts = structuredAgentSessionAttemptFailureParts( + entry.lastFailure, + context, + readWholeAgentSessionFailureFact(recorded?.rejection) + ) return agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts( - entry.lastFailure, - context, - readWholeAgentSessionFailureFact(recorded?.rejection) - ) + resendsOnNewOwner && structuredAgentSessionEntryWaitsForNewOwner(entry) + ? [...parts, 'resendsOnRestart'] + : parts ) } @@ -125,29 +138,33 @@ export function structuredAgentSessionDeliveryNotices( /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ - startFailures: readonly AgentSessionFailureFact[] + startFailures: readonly AgentSessionFailureFact[], + /** The host is known to be older: what it refused for want of an owner goes out on its next. */ + resendsOnNewOwner = false ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null + const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 const rejected = new Map( submissions .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => [submission.clientMessageId, submission]) ) const notices = new Map() - for (const entry of outbox) { + for (const [index, entry] of outbox.entries()) { if ( entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) || entry.clientMessageId === held ) { // Its own Retry is the step, so the words leave out sending again. - const retryControl = held === null || entry.clientMessageId === held + const retryControl = stalledFrom === -1 || index <= stalledFrom const text = deliveryNoticeText( entry, { agentName, retryControl }, rejected.get(entry.clientMessageId), - startFailures + startFailures, + resendsOnNewOwner ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 916abd3ec765..539992b7a9fd 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -20,7 +20,7 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' +import { structuredAgentSessionEntryWaitsForNewOwner } from '../../../../shared/structured-agent-session-outbox-admission' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -76,13 +76,13 @@ export function requeueInterruptedStructuredAgentSessionDispatches( ) } -/** An older host's new owner is its word that a send it refused may now land (see - * `useStructuredAgentSessionOutboxOwnerChange`), so what it refused goes out again, same id. */ +/** An older host's new owner is its word that a send it refused for want of one may now land (see + * `useStructuredAgentSessionOutboxOwnerChange`), so that send goes out again, same id. */ export function releaseStructuredAgentSessionRefusalsForNewOwner( entries: StructuredAgentSessionOutboxEntry[] ): StructuredAgentSessionOutboxEntry[] { return entries.map((entry) => { - if (!structuredAgentSessionEntryHeldForRetry(entry)) { + if (!structuredAgentSessionEntryWaitsForNewOwner(entry)) { return entry } const { lastFailure: _released, ...released } = entry @@ -92,7 +92,8 @@ export function releaseStructuredAgentSessionRefusalsForNewOwner( export function dispatchStructuredAgentSessionOutboxEntry(args: { next: StructuredAgentSessionOutboxEntry - persisted: readonly StructuredAgentSessionOutboxEntry[] + /** The outbox to stage `next` in: this view's, so a hold only memory keeps survives it. */ + entries: readonly StructuredAgentSessionOutboxEntry[] sessionId: string target: RuntimeClientTarget fence: number @@ -108,18 +109,19 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { const start = async (): Promise => { args.inFlightIdRef.current = args.next.clientMessageId const staged = updateStructuredAgentSessionOutboxEntry( - args.persisted, + args.entries, args.next.clientMessageId, (entry) => stageStructuredAgentSessionOutboxEntryForSend(entry, Date.now()) ) if (!writeOutbox(args.sessionId, staged)) { args.inFlightIdRef.current = null - // Held for Retry in memory: the save that would record the hold is what failed. + // Held for Retry. Saved if storage takes this one write; otherwise only memory holds it. const held = updateStructuredAgentSessionOutboxEntry( args.outboxRef.current, args.next.clientMessageId, (entry) => ({ ...entry, lastFailure: { kind: 'failed' } }) ) + writeOutbox(args.sessionId, held) args.outboxRef.current = held args.setOutbox(held) args.setError('Message could not be saved to the outbox') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts index d5c51909335e..ce603ffb6f49 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry.ts @@ -2,7 +2,10 @@ // id when the recorded one can only ever replay a settled rejection. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + structuredAgentSessionEntryIdExpired, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' import { writeOutbox } from './structured-agent-session-outbox-storage' export function retryStructuredAgentSessionOutboxEntry(args: { @@ -20,10 +23,16 @@ export function retryStructuredAgentSessionOutboxEntry(args: { // The host settled this id as rejected, and reusing it only replays that forever, so rotate the // id for a safe resend. Read from the message itself, which outlives a restart, or from a // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that - // settled the message already rotated it. + // settled the message already rotated it. An expired id is refused for good; its row told the + // user to check the chat first. const recordedRejection = current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' - if (current && (recordedRejection || submission?.dispatchState === 'rejected')) { + if ( + current && + (recordedRejection || + submission?.dispatchState === 'rejected' || + structuredAgentSessionEntryIdExpired(current)) + ) { const rotated = outboxRef.current.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 68facefde117..c5bf4600aacb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -10,15 +10,15 @@ import { appendNativeChatAttachmentCache } from './use-native-chat-composer-atta * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, * after whatever is there. Called before the entries leave storage, so a failure between the two * repeats the text rather than losing it. Only this client's outbox holds them, so no other viewer - * gets them. False when no composer shows this session. + * gets them. */ function restoreWithdrawnMessages( sessionId: string, composerScopeKey: string | undefined, withdrawn: readonly StructuredAgentSessionOutboxEntry[] -): boolean { - if (!composerScopeKey) { - return false +): void { + if (!composerScopeKey || withdrawn.length === 0) { + return } // What storage no longer holds was already given back by whichever view dropped it first. const held = new Set( @@ -42,7 +42,6 @@ function restoreWithdrawnMessages( ) ) } - return true } export function useStructuredAgentSessionWithdrawnRestore( @@ -57,8 +56,6 @@ export function useStructuredAgentSessionWithdrawnRestore( ) => void /** Entries a Stop took out of the outbox here, before the host held them. */ byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void - /** Entries no send can deliver any more; false when no composer shows this session. */ - toComposer: (entries: readonly StructuredAgentSessionOutboxEntry[]) => boolean } { return useMemo( () => ({ @@ -79,10 +76,7 @@ export function useStructuredAgentSessionWithdrawnRestore( entries.filter((entry) => withdrawn.has(entry.clientMessageId)) ) }, - byStop: (entries) => { - restoreWithdrawnMessages(sessionId, composerScopeKey, entries) - }, - toComposer: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) + byStop: (entries) => restoreWithdrawnMessages(sessionId, composerScopeKey, entries) }), [composerScopeKey, sessionId] ) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx new file mode 100644 index 000000000000..27f38b8d4959 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +// A message the chat said was not sent goes out again on a new owner only on a host known to be +// older. A host whose capability probe has not answered, or failed, says nothing about that, so +// the message waits for its Retry. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const regained: (() => void)[] = [] + return { call: vi.fn(), probe: vi.fn(), regained } +}) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + runtimeEnvironmentSupportsCapability: mocks.probe +})) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (_environmentId: string, listener: () => void) => { + mocks.regained.push(listener) + return () => {} + } +})) + +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +type SendRequest = { envelope?: { clientOperationId?: string } } + +function requestId(params: SendRequest | undefined): string { + return String(params?.envelope?.clientOperationId) +} + +function sentIds(): string[] { + return mocks.call.mock.calls.map((call) => requestId(call[2])) +} + +async function settle(): Promise { + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) +} + +function mount(fence: number) { + return renderHook( + ({ fence: current }: { fence: number }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'environment', environmentId: 'env-1' }, + fence: current, + submissions: [] + }), + { initialProps: { fence } } + ) +} + +function refusal(code: string) { + return { ok: false, refusal: { code, message: code } } +} + +async function refusedOnce(code: string) { + mocks.call.mockResolvedValue(refusal(code)) + const hook = mount(1) + await settle() + act(() => void hook.result.current.send('hello')) + await settle() + expect(sentIds()).toHaveLength(1) + expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() + return hook +} + +describe('a refused message when the chat gets a new owner', () => { + beforeEach(() => { + localStorage.clear() + mocks.call.mockReset() + mocks.probe.mockReset() + mocks.regained.length = 0 + }) + + afterEach(() => cleanup()) + + it('is not sent on a current host whose capability probe fails after the owner moved', async () => { + mocks.probe.mockResolvedValue(true) + const hook = await refusedOnce('agent_session_checkpoint_stale') + hook.rerender({ fence: 2 }) + await settle() + mocks.probe.mockRejectedValueOnce(new Error('status timeout')) + act(() => mocks.regained.forEach((listener) => listener())) + await settle() + expect(sentIds()).toHaveLength(1) + expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() + }) + + it('is not sent while the capability probe has not answered', async () => { + mocks.probe.mockReturnValue(new Promise(() => {})) + const hook = await refusedOnce('agent_session_checkpoint_stale') + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toHaveLength(1) + }) + + it.each([ + 'agent_session_owner_restart_failed', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'execution_owner_reconciling' + ] as const)('is sent again, same id, on a host known to be older, after %s', async (code) => { + mocks.probe.mockResolvedValue(false) + // Held under an id already sent once, which a settled refusal would otherwise have replaced. + writeOutbox('session-1', [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'op-held', + sessionId: 'session-1', + text: 'hello', + attachments: [], + queuedAt: 1 + }), + lastAttemptAt: 2, + lastFailure: { kind: 'refused', code } + } + ]) + mocks.call.mockResolvedValue({ ok: true, replayed: false, fence: 2, value: { queued: {} } }) + const hook = mount(1) + await settle() + expect(sentIds()).toEqual([]) + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toEqual(['op-held']) + }) + + // A new owner answers none of these, so a copy the user retyped is never joined by the original. + it.each(['agent_session_journal_unreadable', 'agent_session_operation_capacity'])( + 'waits for its Retry on a host known to be older, after %s', + async (code) => { + mocks.probe.mockResolvedValue(false) + const hook = await refusedOnce(code) + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toHaveLength(1) + expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() + } + ) + + it('waits for its Retry on a host known to be older when the send never reached it', async () => { + mocks.probe.mockResolvedValue(false) + mocks.call.mockRejectedValueOnce(new Error('send failed')) + const hook = mount(1) + await settle() + act(() => void hook.result.current.send('hello')) + await settle() + expect(hook.result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' }) + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index b946365ba0d6..01111882ed02 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -24,7 +24,8 @@ import { clearNativeChatDraftCacheForTests, readNativeChatDraftCache } from './native-chat-draft-cache' -import { STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE } from '../../../../shared/structured-agent-session-send-disposition' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' const SESSION = 'session-1' @@ -290,6 +291,42 @@ describe('a message an older host refused, across a relaunch', () => { }) }) +// A message left on its way out is in doubt after a relaunch, whatever an earlier attempt failed +// with: the unconfirmed probe resends it under its id rather than holding it for a Retry. +describe('a message in doubt that carries an earlier failure', () => { + afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + }) + + it('is probed and sent again, not held', async () => { + // What an earlier build left: on its way out, beside a failure it never cleared. + const inDoubt = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'op-in-doubt', + sessionId: SESSION, + text: 'hello', + attachments: [], + queuedAt: 1 + }), + state: 'dispatching' as const, + lastAttemptAt: 2, + lastFailure: { kind: 'refused' as const, code: 'execution_owner_reconciling' as const } + } + writeOutbox(SESSION, [inDoubt]) + hostAccepts() + const { result } = mount() + await waitFor(() => expect(result.current.outbox).toHaveLength(0), { timeout: 5000 }) + expect(sentIds()).toEqual(['op-in-doubt']) + }) +}) + describe('a message whose send could not be saved before it went out', () => { afterEach(() => { cleanup() @@ -328,11 +365,87 @@ describe('a message whose send could not be saved before it went out', () => { expect(sentIds()).not.toContain(firstId) expect(noticeFor(result.current, firstId)?.onRetry).toBeDefined() }) + + function failingWrites(failing: readonly number[]): void { + const save = localStorage.setItem.bind(localStorage) + let writes = 0 + vi.spyOn(localStorage, 'setItem').mockImplementation((key: string, value: string) => { + writes += 1 + if (failing.includes(writes)) { + throw new Error('storage full') + } + save(key, value) + }) + } + + it('stays held when the message behind it goes out, though no save recorded the hold', async () => { + hostAccepts() + // Both messages save; the save marking the first on its way out, and the one recording its + // hold, fail; storage then works again for the second. + failingWrites([3, 4]) + const detached: { fence: number | null } = { fence: null } + const { result, rerender } = renderHook( + ({ fence }: { fence: number | null }) => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: { kind: 'local' }, + fence, + submissions: [] + }), + { initialProps: detached } + ) + act(() => expect(result.current.send('first')).toBe(true)) + act(() => expect(result.current.send('second')).toBe(true)) + const [firstId, secondId] = result.current.outbox.map((entry) => entry.clientMessageId) + rerender({ fence: 1 }) + await waitFor(() => expect(result.current.outbox).toHaveLength(1)) + await settle() + expect(sentIds()).toEqual([secondId]) + expect(result.current.outbox[0]).toMatchObject({ + clientMessageId: firstId, + lastFailure: { kind: 'failed' } + }) + expect(noticeFor(result.current, firstId!)?.onRetry).toBeDefined() + }) + + it('stays held across a relaunch once storage takes the hold', async () => { + hostAccepts() + // The send saves; the save marking it on its way out fails; the hold's own save goes through. + failingWrites([2]) + const before = mount() + act(() => expect(before.result.current.send('first')).toBe(true)) + await waitFor(() => + expect(before.result.current.error).toBe('Message could not be saved to the outbox') + ) + const firstId = before.result.current.outbox[0]!.clientMessageId + before.unmount() + vi.restoreAllMocks() + + const after = mount() + await settle() + expect(mocks.call).not.toHaveBeenCalled() + expect(noticeFor(after.result.current, firstId)?.onRetry).toBeDefined() + }) }) -// A host forgets an operation id a day after it was made; after that the kept id is refused for -// good, and a new one could deliver a message an earlier attempt already delivered. -describe('a held message retried after its id expired', () => { +// A host forgets an operation id a day after it was made and refuses it for good after that. An +// earlier attempt under that id may already be in the chat, so the message stays a held row that +// says so; only the user's Retry sends it, under a new id. +describe('a held message whose id expired', () => { + const DAY = 24 * 60 * 60 * 1000 + const EXPIRED_WORDS = "Orca couldn't confirm what happened. Check the chat." + + function expired() { + return { + ok: false, + refusal: { + code: 'agent_session_operation_expired', + message: 'Operation expired.', + details: { reason: 'operationExpired' } + } + } + } + afterEach(() => { cleanup() setLocalRuntimeCapabilitiesForTests(null) @@ -345,16 +458,51 @@ describe('a held message retried after its id expired', () => { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) }) - it('goes back to the composer with a notice, and is not sent again', async () => { - mocks.call.mockResolvedValueOnce(newerOrcaRefusal()) - mocks.call.mockResolvedValue({ - ok: false, - refusal: { - code: 'agent_session_operation_expired', - message: 'Operation expired.', - details: { reason: 'operationExpired' } - } - }) + it('stays a row that says it may be in the chat; its Retry sends it under a new id', async () => { + mocks.call.mockResolvedValueOnce(newerOrcaRefusal()).mockResolvedValueOnce(expired()) + const before = mount() + act(() => expect(before.result.current.send('hello')).toBe(true)) + await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined()) + const keptId = sentIds()[0]! + act(() => before.result.current.retry(keptId)) + await waitFor(() => + expect(before.result.current.outbox[0]?.lastFailure).toMatchObject({ + code: 'agent_session_operation_expired' + }) + ) + expect(before.result.current.error).toBeNull() + before.unmount() + + hostAccepts() + const after = mount() + await settle() + expect(mocks.call).toHaveBeenCalledTimes(2) + const notice = noticeFor(after.result.current, keptId) + expect(notice?.text).toBe(EXPIRED_WORDS) + expect(notice?.onRetry).toBeDefined() + + act(() => notice?.onRetry?.()) + await waitFor(() => expect(after.result.current.outbox).toHaveLength(0)) + expect(sentIds()).toHaveLength(3) + expect(sentIds()[2]).not.toBe(keptId) + }) + + it('stays in the chat, not the composer, when a relaunch resends it on its own', async () => { + // Quit mid-send two days ago: the first message was on its way, the second queued behind it. + const old = Date.now() - 2 * DAY + const inFlight = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: `${old}-${'1'.repeat(32)}`, + sessionId: SESSION, + text: 'first', + attachments: [], + queuedAt: old + }), + state: 'dispatching' as const, + lastAttemptAt: old + } + writeOutbox(SESSION, [inFlight]) + mocks.call.mockResolvedValue(expired()) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ sessionId: SESSION, @@ -364,14 +512,18 @@ describe('a held message retried after its id expired', () => { composerScopeKey: 'pane-1' }) ) - act(() => expect(result.current.send('hello')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) - - act(() => result.current.retry(result.current.outbox[0]!.clientMessageId)) - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - expect(readNativeChatDraftCache('pane-1')).toBe('hello') - expect(result.current.error).toBe(STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE) + await waitFor(() => expect(mocks.call).toHaveBeenCalled(), { timeout: 5000 }) + await waitFor(() => + expect(result.current.outbox[0]?.lastFailure).toMatchObject({ + code: 'agent_session_operation_expired' + }) + ) await settle() - expect(mocks.call).toHaveBeenCalledTimes(2) + expect(readNativeChatDraftCache('pane-1')).toBe('') + expect(result.current.outbox).toMatchObject([ + { clientMessageId: inFlight.clientMessageId, state: 'queued' } + ]) + expect(noticeFor(result.current, inFlight.clientMessageId)?.text).toBe(EXPIRED_WORDS) + expect(sentIds()).toEqual([inFlight.clientMessageId]) }) }) 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 6c53f6b88ddf..30784d49c86d 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 @@ -57,9 +57,14 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { const timer = setTimeout( () => { probeAttemptsRef.current = { id: probeId, attempts: attempts + 1 } - const next = outboxRef.current.map((entry) => - entry.clientMessageId === probeId ? { ...entry, state: 'queued' as const } : entry - ) + const next = outboxRef.current.map((entry) => { + if (entry.clientMessageId !== probeId) { + return entry + } + // A saved failure would hold it for a Retry instead of resending it. + const { lastFailure: _probed, ...probed } = entry + return { ...probed, state: 'queued' as const } + }) outboxRef.current = next setOutbox(next) writeOutbox(sessionId, next) 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 6af649d6ba6b..5972a1aec0b3 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 @@ -83,8 +83,9 @@ export function useStructuredAgentSessionOutbox(args: { // "which entry" must never disagree: the journal can settle the tail while the head moves. const inFlightIdRef = useRef(null) const dispatchGenerationRef = useRef(0) - // The last owner this session's outbox saw; a later one is an owner change, a mount is not. - const lastOwnerRef = useRef(owner.ownerChange) + // The last owner an older host's refusals here waited on; a later one is a new owner, a mount or + // a session change is not. + const refusalOwnerRef = useRef({ sessionId, owner: owner.refusalOwner }) const [error, setError] = useState(null) const [errorSession, setErrorSession] = useState(sessionId) // Render-time reset (react.dev: adjusting state when a prop changes), so the @@ -106,23 +107,10 @@ export function useStructuredAgentSessionOutbox(args: { useEffect(() => { const sessionChanged = outboxSessionRef.current !== sessionId outboxSessionRef.current = sessionId - const lastOwner = lastOwnerRef.current - if (sessionChanged || owner.ownerChange !== null) { - lastOwnerRef.current = owner.ownerChange - } - const ownerMoved = - !sessionChanged && - lastOwner !== null && - owner.ownerChange !== null && - lastOwner !== owner.ownerChange const current = sessionChanged ? readMountedStructuredAgentSessionOutbox(sessionId, owner.fenceRef.current, readOutbox) : outboxRef.current - const requeued = requeueInterruptedStructuredAgentSessionDispatches( - current, - owner.fenceRef.current - ) - const next = ownerMoved ? releaseStructuredAgentSessionRefusalsForNewOwner(requeued) : requeued + const next = requeueInterruptedStructuredAgentSessionDispatches(current, owner.fenceRef.current) if ( sessionChanged || next.some((entry, index) => entry !== current[index]) || @@ -134,6 +122,25 @@ export function useStructuredAgentSessionOutbox(args: { } }, [owner.fenceRef, owner.ownerChange, sessionId, target]) + useEffect(() => { + const last = refusalOwnerRef.current + // A host not heard from says nothing about its owner, so the last known one stands. + if (owner.refusalOwner === null) { + return + } + refusalOwnerRef.current = { sessionId, owner: owner.refusalOwner } + if (last.sessionId !== sessionId || last.owner === null || last.owner === owner.refusalOwner) { + return + } + const current = outboxRef.current + const next = releaseStructuredAgentSessionRefusalsForNewOwner(current) + if (next.some((entry, index) => entry !== current[index])) { + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + } + }, [owner.refusalOwner, sessionId]) + useEffect(() => { const current = outboxRef.current const hostOwns = new Set([ @@ -182,22 +189,12 @@ export function useStructuredAgentSessionOutbox(args: { // Released here rather than in a `.finally`: the state write below is what re-runs the // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null - const returned = disposition.returnedToComposer ?? [] - // Before the write, so a failure between the two repeats the text rather than losing it. - // With no composer to take it, the message stays held with its failure. - const entries = - returned.length === 0 || restoreWithdrawn.toComposer(returned) - ? disposition.entries - : outboxRef.current.map( - (entry) => - returned.find((kept) => kept.clientMessageId === entry.clientMessageId) ?? entry - ) - setError(entries === disposition.entries ? disposition.error : null) - outboxRef.current = entries - setOutbox(entries) - writeOutbox(sessionId, entries) + setError(disposition.error) + outboxRef.current = disposition.entries + setOutbox(disposition.entries) + writeOutbox(sessionId, disposition.entries) }, - [restoreWithdrawn, sessionId] + [sessionId] ) useEffect(() => { @@ -255,7 +252,9 @@ export function useStructuredAgentSessionOutbox(args: { const dispatchGeneration = dispatchGenerationRef.current const dispatch = dispatchStructuredAgentSessionOutboxEntry({ next: attempt.wire, - persisted: persisted.map((entry) => (entry === persistedEntry ? attempt.stored : entry)), + entries: outbox.map((entry) => + entry.clientMessageId === next.clientMessageId ? attempt.stored : entry + ), sessionId, target, fence, @@ -336,6 +335,8 @@ export function useStructuredAgentSessionOutbox(args: { return { outbox, error, + /** A refusal the host gave for want of an owner goes out again on its next one. */ + resendsOnNewOwner: owner.olderHost, send, retry, withdrawUnsent diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 29abbc676770..5bd1cce89182 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -213,6 +213,7 @@ export function useStructuredAgentSession(args: { loadOlder, prompts, outbox, + resendsOnNewOwner: outboxController.resendsOnNewOwner, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, // A message typed during a command queues behind it on the host. diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 3c57af388225..d135e3c6b8ad 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2858,6 +2858,7 @@ "recordFailed": "Orca couldn't record it in this chat's history.", "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", + "resendsOnRestart": "Orca will send it again when the agent restarts.", "restartFailed": "The agent couldn't restart.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 69264dc34789..c775b6e19756 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17536,6 +17536,7 @@ "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", "restartFailed": "The agent couldn't restart.", + "resendsOnRestart": "Orca will send it again when the agent restarts.", "capacity": "Orca has received too many requests in the last day.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "questionChanged": "This question was already answered or has changed.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c28987a4d819..86db3daabb9b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14974,6 +14974,7 @@ "notDoneCommand": "El comando no se ejecutó.", "notDoneGoal": "El objetivo no se cambió.", "restartFailed": "El agente no pudo reiniciarse.", + "resendsOnRestart": "Orca lo volverá a enviar cuando el agente se reinicie.", "capacity": "Orca ha recibido demasiadas solicitudes en el último día.", "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 69deaa9ff669..683189f34df5 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17448,6 +17448,7 @@ "notDoneCommand": "La commande n'a pas été exécutée.", "notDoneGoal": "L'objectif n'a pas été modifié.", "restartFailed": "L'agent n'a pas pu redémarrer.", + "resendsOnRestart": "Orca le renverra quand l'agent redémarrera.", "capacity": "Orca a reçu trop de requêtes au cours de la dernière journée.", "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d7de27a0aef0..19c874272798 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17384,6 +17384,7 @@ "notDoneCommand": "コマンドは実行されませんでした。", "notDoneGoal": "目標は変更されませんでした。", "restartFailed": "Agent を再起動できませんでした。", + "resendsOnRestart": "Agent が再起動したら、Orca が再送信します。", "capacity": "Orca が過去 1 日に受け取ったリクエストが多すぎます。", "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3e19e9a23ac3..bb25afa0d1eb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17384,6 +17384,7 @@ "notDoneCommand": "명령이 실행되지 않았습니다.", "notDoneGoal": "목표가 변경되지 않았습니다.", "restartFailed": "에이전트를 다시 시작하지 못했습니다.", + "resendsOnRestart": "에이전트가 다시 시작되면 Orca가 다시 보냅니다.", "capacity": "Orca가 지난 하루 동안 너무 많은 요청을 받았습니다.", "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6473e8cba697..06c7afa8a0e7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17349,6 +17349,7 @@ "notDoneCommand": "命令未运行。", "notDoneGoal": "目标未更改。", "restartFailed": "智能体无法重新启动。", + "resendsOnRestart": "智能体重新启动后,Orca 会重新发送。", "capacity": "Orca 在过去一天内收到的请求过多。", "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "questionChanged": "此问题已被回答或已更改。", diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx index acdfdbb03233..e9da06acee1f 100644 --- a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx @@ -11,7 +11,7 @@ vi.mock('./runtime-rpc-client', () => ({ })) import { setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' -import { useStructuredAgentSessionHostAcceptsSend } from './structured-agent-session-accepted-send-capability' +import { useStructuredAgentSessionHostAcceptedSendState } from './structured-agent-session-accepted-send-capability' afterEach(() => { setLocalRuntimeCapabilitiesForTests(null) @@ -21,31 +21,41 @@ afterEach(() => { describe('whether a host accepts a send before any agent has it', () => { it('reads the local host from its advertised capabilities', () => { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) - const { result } = renderHook(() => useStructuredAgentSessionHostAcceptsSend({ kind: 'local' })) - expect(result.current).toBe(true) + const { result } = renderHook(() => + useStructuredAgentSessionHostAcceptedSendState({ kind: 'local' }) + ) + expect(result.current).toBe('supported') }) it('treats a local host that does not advertise it as an older one', () => { setLocalRuntimeCapabilitiesForTests([]) - const { result } = renderHook(() => useStructuredAgentSessionHostAcceptsSend({ kind: 'local' })) - expect(result.current).toBe(false) + const { result } = renderHook(() => + useStructuredAgentSessionHostAcceptedSendState({ kind: 'local' }) + ) + expect(result.current).toBe('unsupported') }) - it('asks a remote host, and reads a failed probe as an older host', async () => { + it('asks a remote host, and reads a failed probe as not yet known', async () => { mocks.supports.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('offline')) const accepts = renderHook(() => - useStructuredAgentSessionHostAcceptsSend({ kind: 'environment', environmentId: 'env-1' }) + useStructuredAgentSessionHostAcceptedSendState({ + kind: 'environment', + environmentId: 'env-1' + }) ) - await waitFor(() => expect(accepts.result.current).toBe(true)) + await waitFor(() => expect(accepts.result.current).toBe('supported')) expect(mocks.supports).toHaveBeenCalledWith( 'env-1', AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ) const offline = renderHook(() => - useStructuredAgentSessionHostAcceptsSend({ kind: 'environment', environmentId: 'env-2' }) + useStructuredAgentSessionHostAcceptedSendState({ + kind: 'environment', + environmentId: 'env-2' + }) ) await waitFor(() => expect(mocks.supports).toHaveBeenCalledTimes(2)) - expect(offline.result.current).toBe(false) + expect(offline.result.current).toBe('unknown') }) }) diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts index 89789e012495..5c979d999ab6 100644 --- a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts @@ -3,45 +3,57 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../shared/ import type { RuntimeClientTarget } from './runtime-client-target' import { structuredAgentSessionHostKey, - useStructuredAgentSessionHostCapability + useStructuredAgentSessionHostCapabilityState, + type StructuredAgentSessionHostCapabilityState } from './structured-agent-session-host-capability' /** * Whether the host answers a send at acceptance and never refuses one because its agent could not * start. Such a host records every send before it starts anything, so nothing about a moved fence - * calls for a resend. False until the host has said so: an older host is handled as it always was. + * calls for a resend. `unknown` until the host has said so, and after a failed probe. */ -export function useStructuredAgentSessionHostAcceptsSend(target: RuntimeClientTarget): boolean { - return useStructuredAgentSessionHostCapability( +export function useStructuredAgentSessionHostAcceptedSendState( + target: RuntimeClientTarget +): StructuredAgentSessionHostCapabilityState { + return useStructuredAgentSessionHostCapabilityState( target, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ) } /** - * When an outbox treats its owner as changed: resending a send in flight under the same id, - * dropping that send's answer, and releasing a refused message. An older host restarts the agent - * inside the send and refuses it, unrecorded, when that fails, so a new fence is its only word that - * another try may land. A host that accepts first records every send before it starts anything, - * so a moved fence means nothing there, and only a Retry or a new send goes out. + * When an outbox treats its owner as changed. An older host restarts the agent inside the send and + * refuses it, unrecorded, when that fails, so a new fence is its only word that another try may + * land. A host that accepts first records every send before it starts anything, so a moved fence + * means nothing there, and only a Retry or a new send goes out. */ export function useStructuredAgentSessionOutboxOwnerChange( target: RuntimeClientTarget, fence: number | null ): { + /** Resends a send in flight under the same id and drops its answer; any host not known to + * accept first is handled as an older one, as nothing it says is lost that way. */ ownerChange: number | null + /** Releases a message the host refused, which was shown as not sent: only a host known to be + * older, so one not yet heard from never sends it without its Retry. */ + refusalOwner: number | null + /** Whether the host is known to be older, so a refusal it gave for want of an owner goes out + * again on its next one. */ + olderHost: boolean attached: boolean fenceRef: RefObject targetKey: string } { - const acceptsSend = useStructuredAgentSessionHostAcceptsSend(target) + const acceptedSend = useStructuredAgentSessionHostAcceptedSendState(target) // Read by effects that run on an owner change, not on every fence move. const fenceRef = useRef(fence) useLayoutEffect(() => { fenceRef.current = fence }, [fence]) return { - ownerChange: acceptsSend ? null : fence, + ownerChange: acceptedSend === 'supported' ? null : fence, + refusalOwner: acceptedSend === 'unsupported' ? fence : null, + olderHost: acceptedSend === 'unsupported', attached: fence !== null, fenceRef, targetKey: structuredAgentSessionHostKey(target) diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index e5e6d011311b..e9a31ae7ab69 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -25,6 +25,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { notDoneCommand: "The command didn't run.", notDoneGoal: "The goal wasn't changed.", restartFailed: "The agent couldn't restart.", + resendsOnRestart: 'Orca will send it again when the agent restarts.', capacity: 'Orca has received too many requests in the last day.', outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", questionChanged: 'This question was already answered or has changed.', diff --git a/src/shared/structured-agent-session-outbox-admission.ts b/src/shared/structured-agent-session-outbox-admission.ts index 3d7e95889342..2822603faa46 100644 --- a/src/shared/structured-agent-session-outbox-admission.ts +++ b/src/shared/structured-agent-session-outbox-admission.ts @@ -1,5 +1,6 @@ // Which outbox entry goes out next, and which ones wait for the user. +import type { AgentSessionWireRefusalCode } from './agent-session-wire' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' /** A send the user was told did not go through, with a Retry: only that Retry sends it again. Read @@ -10,6 +11,27 @@ export function structuredAgentSessionEntryHeldForRetry( return entry.state === 'queued' && entry.lastFailure !== undefined } +// What a host from before accepted sends refuses with while it restarts the chat's agent inside the +// send (its restart failed, or met an owner still settling); a new owner is what answers them. +const REFUSED_FOR_OWNER: ReadonlySet = new Set([ + 'agent_session_owner_restart_failed', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'execution_owner_reconciling' +]) + +/** On a host known to be older, a held message that host's next owner sends again. */ +export function structuredAgentSessionEntryWaitsForNewOwner( + entry: StructuredAgentSessionOutboxEntry +): boolean { + return ( + structuredAgentSessionEntryHeldForRetry(entry) && + entry.lastFailure?.kind === 'refused' && + REFUSED_FOR_OWNER.has(entry.lastFailure.code) + ) +} + export type StructuredAgentSessionOutboxAdmission = | { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry } | { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry } diff --git a/src/shared/structured-agent-session-outbox-retry-hold.test.ts b/src/shared/structured-agent-session-outbox-retry-hold.test.ts index bac99301cbb8..3f0c439fc9a3 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -76,4 +76,28 @@ describe('a message held for its Retry', () => { expect(landed?.state).toBe('dispatching') expect(landed?.lastFailure).toBeUndefined() }) + + // Any fresh word on where a message stands supersedes an earlier attempt's failure. + it('is in doubt, and holds the queue, once the host says it cannot tell whether it landed', () => { + const submission: AgentJournalSubmission = { + clientMessageId: 'held', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 5, + resolvedAt: null + } + const reconciled = reconcileStructuredAgentSessionOutbox( + [entry('held', { lastAttemptAt: 2, lastFailure: REFUSED }), entry('next')], + [submission] + ) + expect(reconciled[0]).toMatchObject({ state: 'unconfirmed' }) + expect(reconciled[0]?.lastFailure).toBeUndefined() + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'blocked', + entry: { clientMessageId: 'held' } + }) + }) }) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 036d1af9d560..d7ee2f59d3dd 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -161,6 +161,19 @@ export function stageStructuredAgentSessionOutboxEntryForSend( return { ...entry, state: 'dispatching', lastAttemptAt: now } } +/** The host forgot this message's id, a day after it was made, and refuses it for good: only a new + * id sends it. The id was kept because an earlier attempt under it may already be in the chat; a + * first attempt's was replaced when it was refused. */ +export function structuredAgentSessionEntryIdExpired( + entry: StructuredAgentSessionOutboxEntry +): boolean { + return ( + entry.state === 'queued' && + entry.lastFailure?.kind === 'refused' && + entry.lastFailure.code === 'agent_session_operation_expired' + ) +} + export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, refusal: AgentSessionWriteRefusal, @@ -237,7 +250,9 @@ export function reconcileStructuredAgentSessionOutbox( entry.retryAfterUnknownSubmittedAt !== -1 && entry.retryAfterUnknownSubmittedAt !== submission.submittedAt ) { - return [{ ...entry, state: 'unconfirmed' as const }] + // In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send. + const { lastFailure: _superseded, ...inDoubt } = entry + return [{ ...inDoubt, state: 'unconfirmed' as const }] } return [entry] }) diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 928fc2f45f73..98ea2ff8b676 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -15,7 +15,6 @@ import { agentSessionWriteNoticeEnglish } from './agent-session-refusal-notice' import { disposeStructuredAgentSessionSendFailure, disposeStructuredAgentSessionSendResult, - STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE, structuredAgentSessionAttemptFailureParts } from './structured-agent-session-send-disposition' import { @@ -370,7 +369,7 @@ describe('ambiguous operation refusals', () => { { ...entry, state: 'unconfirmed' as const, lastAttemptAt: 10 }, { ...entry, state: 'queued' as const, lastAttemptAt: 10, retryAfterUnknownSubmittedAt: 10 } ])( - 'never rotates $state operation after its host tombstone expires; gives its text back', + 'never rotates $state operation after its host tombstone expires; holds it for its Retry', (ambiguous) => { const disposition = disposeStructuredAgentSessionSendResult({ entries: [ambiguous], @@ -385,12 +384,16 @@ describe('ambiguous operation refusals', () => { createOperationId: () => 'fresh-id' }) - // No id can deliver it now: never a fresh one, and the kept one is refused for good. - expect(disposition.entries).toEqual([]) - expect(disposition.returnedToComposer).toMatchObject([ - { clientMessageId: entry.clientMessageId, state: 'queued' } + // An earlier attempt under the kept id may have landed, so no new id goes out on its own. + expect(disposition.entries).toMatchObject([ + { + clientMessageId: entry.clientMessageId, + state: 'queued', + lastFailure: { kind: 'refused', code: 'agent_session_operation_expired' } + } ]) - expect(disposition.error).toBe(STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE) + expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true) + expect(disposition.error).toBeNull() } ) @@ -406,7 +409,6 @@ describe('ambiguous operation refusals', () => { }) expect(disposition.entries).toMatchObject([{ clientMessageId: 'fresh-id', state: 'rejected' }]) - expect(disposition.returnedToComposer).toBeUndefined() }) it('parks a recovered missing submission without polling forever', () => { diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 4d0e72f8719a..f717e54f2d43 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -34,13 +34,8 @@ export type StructuredAgentSessionSendDisposition = { entries: StructuredAgentSessionOutboxEntry[] /** Only for an outcome with no entry left to carry it; a kept entry holds its own failure. */ error: string | null - /** Messages no send can deliver any more, whose text goes back to the composer. */ - returnedToComposer?: StructuredAgentSessionOutboxEntry[] } -export const STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE = - "This message can no longer be sent, so it's back in the message box. Check the chat before sending it again." - type SendDispositionInput = { entries: readonly StructuredAgentSessionOutboxEntry[] entry: StructuredAgentSessionOutboxEntry @@ -215,16 +210,6 @@ export function disposeStructuredAgentSessionSendRefusal( ) : candidate ) - // Expired is final for an id, and a kept id is one an earlier attempt may have delivered, so no - // Retry could go through and a new id could send it twice: the user decides, from the chat. - const kept = entries.find((entry) => entry.clientMessageId === input.entry.clientMessageId) - if (kept && input.refusal.code === 'agent_session_operation_expired') { - return { - entries: dropEntry(input), - error: STRUCTURED_AGENT_SESSION_EXPIRED_SEND_NOTICE, - returnedToComposer: [kept] - } - } return { entries, error: null } } From 94df00110325682593525398df72897b9669ba6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:50:55 -0700 Subject: [PATCH 3/6] fix(native-chat): a refused message waits for its Retry on every host, and a send is staged from the latest outbox A message the chat showed as not sent no longer goes out on its own when an older host's chat gets a new agent owner. Resending it on the owner change sent it after messages typed later, still delivered a retyped copy twice, and its "Orca will send it again when the agent restarts" row promised a resend that often never came. It now waits for the user's Retry, as it does on every current host. A send still in flight when the owner changes is still sent again under its id; it was never shown as failed. The drain admitted and staged the next send from the render's outbox. An owner change requeues the send it interrupted in an effect earlier in the same commit, and staging from the render's list wrote the old list back, leaving that send stuck as sending. The drain now reads the latest list, which still carries a hold kept only in memory after a failed save. Tests pass the view's target as one stable object, as the view does: a new object each render re-ran the owner-change requeue, which hid the drain bug. --- .../NativeChatStructuredSession.tsx | 12 +- .../agent-session-write-notice-text.ts | 2 - ...red-agent-session-delivery-notices.test.ts | 24 -- ...ructured-agent-session-delivery-notices.ts | 26 +- ...tructured-agent-session-outbox-dispatch.ts | 15 -- ...agent-session-outbox-owner-change.test.tsx | 232 ++++++++++++++++++ ...gent-session-outbox-owner-release.test.tsx | 161 ------------ ...nt-session-outbox-rejection-cause.test.tsx | 13 +- ...gent-session-outbox-relaunch-hold.test.tsx | 20 +- ...-agent-session-outbox-unconfirmed-probe.ts | 3 +- .../use-structured-agent-session-outbox.ts | 38 +-- .../use-structured-agent-session.ts | 1 - .../src/i18n/en-runtime-required.json | 1 - src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - ...-session-accepted-send-capability.test.tsx | 30 +-- ...-agent-session-accepted-send-capability.ts | 34 +-- src/shared/agent-session-write-notice-copy.ts | 1 - ...ructured-agent-session-outbox-admission.ts | 22 -- 23 files changed, 297 insertions(+), 344 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 4fac23008ba3..cef7142094f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -134,17 +134,9 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures, - controller.resendsOnNewOwner + startFailures ), - [ - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.resendsOnNewOwner - ] + [controller.outbox, agentLabel, retryDelivery, rejectionRows, startFailures] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 1bbb90244d3d..e584429ddfbf 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -36,8 +36,6 @@ const SENTENCES: Record string> = { notDoneGoal: () => translate('components.native-chat.writeNotice.notDoneGoal', COPY.notDoneGoal), restartFailed: () => translate('components.native-chat.writeNotice.restartFailed', COPY.restartFailed), - resendsOnRestart: () => - translate('components.native-chat.writeNotice.resendsOnRestart', COPY.resendsOnRestart), capacity: () => translate('components.native-chat.writeNotice.capacity', COPY.capacity), outcomeUnknown: () => translate('components.native-chat.writeNotice.outcomeUnknown', COPY.outcomeUnknown), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index a3fc0814b7c4..bda8f0c8da3d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -395,30 +395,6 @@ describe('the notice on each message that did not go through', () => { }) }) - // An older host's next agent owner sends it again; the Retry stays in case none comes. - it('says a message an older host refused for want of an owner goes again on its restart', () => { - const outbox = [ - entry('owner', { - lastAttemptAt: 1, - lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' } - }), - entry('journal', { - lastAttemptAt: 1, - lastFailure: { kind: 'refused', code: 'agent_session_journal_unreadable' } - }) - ] - const retry = vi.fn() - const older = structuredAgentSessionDeliveryNotices(outbox, 'Claude', retry, [], [], true) - expect(older.get(agentJournalSubmissionKey('owner'))?.text).toBe( - 'Your message was not sent. Orca will send it again when the agent restarts.' - ) - expect(older.get(agentJournalSubmissionKey('owner'))?.onRetry).toBeDefined() - expect(older.get(agentJournalSubmissionKey('journal'))?.text).toBe( - "Orca couldn't read this chat's saved history. Your message was not sent." - ) - expect(texts(outbox)[agentJournalSubmissionKey('owner')]).toBe('Your message was not sent.') - }) - // An earlier attempt under the id may have landed, so the row never says it was not sent. it('words a kept message whose id expired as an outcome Orca cannot confirm', () => { const notices = structuredAgentSessionDeliveryNotices( diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 6e770bdb5822..67c69a26a511 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -30,8 +30,7 @@ import { } from '../../../../shared/structured-agent-session-outbox' import { admitStructuredAgentSessionOutboxEntry, - structuredAgentSessionEntryHeldForRetry, - structuredAgentSessionEntryWaitsForNewOwner + structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' @@ -89,8 +88,7 @@ function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[], - resendsOnNewOwner: boolean + startFailures: readonly AgentSessionFailureFact[] ): string { // A send attempted before a Stop and then interrupted may already be with the host. const attemptedAcrossStop = @@ -117,15 +115,12 @@ function deliveryNoticeText( ) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } - const parts = structuredAgentSessionAttemptFailureParts( - entry.lastFailure, - context, - readWholeAgentSessionFailureFact(recorded?.rejection) - ) return agentSessionWriteNoticeText( - resendsOnNewOwner && structuredAgentSessionEntryWaitsForNewOwner(entry) - ? [...parts, 'resendsOnRestart'] - : parts + structuredAgentSessionAttemptFailureParts( + entry.lastFailure, + context, + readWholeAgentSessionFailureFact(recorded?.rejection) + ) ) } @@ -138,9 +133,7 @@ export function structuredAgentSessionDeliveryNotices( /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ - startFailures: readonly AgentSessionFailureFact[], - /** The host is known to be older: what it refused for want of an owner goes out on its next. */ - resendsOnNewOwner = false + startFailures: readonly AgentSessionFailureFact[] ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -163,8 +156,7 @@ export function structuredAgentSessionDeliveryNotices( entry, { agentName, retryControl }, rejected.get(entry.clientMessageId), - startFailures, - resendsOnNewOwner + startFailures ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 539992b7a9fd..4d3cd3809f45 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -20,7 +20,6 @@ import { updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionEntryWaitsForNewOwner } from '../../../../shared/structured-agent-session-outbox-admission' import { writeOutbox } from './structured-agent-session-outbox-storage' import { getStructuredAgentLaunchPromptDispatch, @@ -76,20 +75,6 @@ export function requeueInterruptedStructuredAgentSessionDispatches( ) } -/** An older host's new owner is its word that a send it refused for want of one may now land (see - * `useStructuredAgentSessionOutboxOwnerChange`), so that send goes out again, same id. */ -export function releaseStructuredAgentSessionRefusalsForNewOwner( - entries: StructuredAgentSessionOutboxEntry[] -): StructuredAgentSessionOutboxEntry[] { - return entries.map((entry) => { - if (!structuredAgentSessionEntryWaitsForNewOwner(entry)) { - return entry - } - const { lastFailure: _released, ...released } = entry - return released - }) -} - export function dispatchStructuredAgentSessionOutboxEntry(args: { next: StructuredAgentSessionOutboxEntry /** The outbox to stage `next` in: this view's, so a hold only memory keeps survives it. */ diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx new file mode 100644 index 000000000000..926d77d47943 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment happy-dom + +// A message the chat said was not sent goes out only on its Retry, on every host, however the +// chat's agent owner moves. A send still in flight when the owner moves, on a host not known to +// record sends before starting an agent, goes out again under its id. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const regained: (() => void)[] = [] + return { call: vi.fn(), probe: vi.fn(), regained } +}) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + runtimeEnvironmentSupportsCapability: mocks.probe +})) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: (_environmentId: string, listener: () => void) => { + mocks.regained.push(listener) + return () => {} + } +})) + +import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { writeOutbox } from './structured-agent-session-outbox-storage' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +type SendRequest = { envelope?: { clientOperationId?: string; expectedRuntimeFence?: number } } + +// Stable, as the view passes it: a new object each render would re-run the owner-change requeue. +const TARGET = { kind: 'environment', environmentId: 'env-1' } as const + +function requestId(params: SendRequest | undefined): string { + return String(params?.envelope?.clientOperationId) +} + +function sentIds(): string[] { + return mocks.call.mock.calls.map((call) => requestId(call[2])) +} + +/** Each send as `id@fence`, so a resend on a new owner is told from the first attempt. */ +function sentAttempts(): string[] { + return mocks.call.mock.calls.map((call) => { + const params: SendRequest | undefined = call[2] + return `${requestId(params)}@${String(params?.envelope?.expectedRuntimeFence)}` + }) +} + +const QUEUED = { ok: true, replayed: false, fence: 2, value: { queued: {} } } + +function heldEntry( + clientMessageId: string, + code: AgentSessionWireRefusalCode +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: 'held', + attachments: [], + queuedAt: 1 + }), + lastAttemptAt: 2, + lastFailure: { kind: 'refused', code } + } +} + +async function settle(): Promise { + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) +} + +function mount(fence: number) { + return renderHook( + ({ fence: current }: { fence: number }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: TARGET, + fence: current, + submissions: [] + }), + { initialProps: { fence } } + ) +} + +function refusal(code: string) { + return { ok: false, refusal: { code, message: code } } +} + +async function refusedOnce(code: string) { + mocks.call.mockResolvedValue(refusal(code)) + const hook = mount(1) + await settle() + act(() => void hook.result.current.send('hello')) + await settle() + expect(sentIds()).toHaveLength(1) + expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() + return hook +} + +describe('a refused message when the chat gets a new owner', () => { + beforeEach(() => { + localStorage.clear() + mocks.call.mockReset() + mocks.probe.mockReset() + mocks.regained.length = 0 + }) + + afterEach(() => cleanup()) + + it('is not sent on a current host whose capability probe fails after the owner moved', async () => { + mocks.probe.mockResolvedValue(true) + const hook = await refusedOnce('agent_session_checkpoint_stale') + hook.rerender({ fence: 2 }) + await settle() + mocks.probe.mockRejectedValueOnce(new Error('status timeout')) + act(() => mocks.regained.forEach((listener) => listener())) + await settle() + expect(sentIds()).toHaveLength(1) + expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() + }) + + it('is not sent while the capability probe has not answered', async () => { + mocks.probe.mockReturnValue(new Promise(() => {})) + const hook = await refusedOnce('agent_session_checkpoint_stale') + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toHaveLength(1) + }) + + it.each([ + 'agent_session_owner_restart_failed', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'execution_owner_reconciling', + 'agent_session_journal_unreadable', + 'agent_session_operation_capacity' + ] as const)( + 'waits for its Retry on a host known to be older when the owner moves, after %s', + async (code) => { + mocks.probe.mockResolvedValue(false) + // Held under an id already sent once, so its Retry keeps the id. + writeOutbox('session-1', [heldEntry('op-held', code)]) + mocks.call.mockResolvedValue(QUEUED) + const hook = mount(1) + await settle() + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toEqual([]) + expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() + + act(() => hook.result.current.retry('op-held')) + await settle() + expect(sentIds()).toEqual(['op-held']) + expect(hook.result.current.outbox).toEqual([]) + } + ) + + it('waits for its Retry on a host known to be older when the send never reached it', async () => { + mocks.probe.mockResolvedValue(false) + mocks.call.mockRejectedValueOnce(new Error('send failed')).mockResolvedValue(QUEUED) + const hook = mount(1) + await settle() + act(() => void hook.result.current.send('hello')) + await settle() + expect(hook.result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' }) + hook.rerender({ fence: 2 }) + await settle() + expect(sentIds()).toHaveLength(1) + + act(() => hook.result.current.retry(hook.result.current.outbox[0]!.clientMessageId)) + await settle() + expect(sentIds()).toHaveLength(2) + expect(hook.result.current.outbox).toEqual([]) + }) + + // The owner change's requeue lands in the same commit as the drain's next send. + it.each([ + ['a host known to be older', false], + ['a host whose capability check has not answered', undefined] + ])( + 'sends again the send in flight when the owner moves on %s, with one queued behind it', + async (_label, supports) => { + mocks.probe.mockReturnValue( + supports === undefined ? new Promise(() => {}) : Promise.resolve(supports) + ) + // The first owner never answers the first send. + mocks.call.mockReturnValueOnce(new Promise(() => {})).mockResolvedValue(QUEUED) + const hook = mount(1) + await settle() + act(() => void hook.result.current.send('first')) + await settle() + act(() => void hook.result.current.send('second')) + await settle() + const [first, second] = hook.result.current.outbox.map((entry) => entry.clientMessageId) + hook.rerender({ fence: 2 }) + await settle() + await settle() + expect(sentAttempts()).toEqual([`${first}@1`, `${first}@2`, `${second}@2`]) + expect(hook.result.current.outbox).toEqual([]) + } + ) + + it('sends the send in flight again on a new owner, but not a message it refused', async () => { + mocks.probe.mockResolvedValue(false) + writeOutbox('session-1', [heldEntry('op-held', 'agent_session_checkpoint_stale')]) + mocks.call.mockReturnValueOnce(new Promise(() => {})).mockResolvedValue(QUEUED) + const hook = mount(1) + await settle() + act(() => void hook.result.current.send('first')) + await settle() + act(() => void hook.result.current.send('second')) + await settle() + const [, first, second] = hook.result.current.outbox.map((entry) => entry.clientMessageId) + hook.rerender({ fence: 2 }) + await settle() + await settle() + expect(sentAttempts()).toEqual([`${first}@1`, `${first}@2`, `${second}@2`]) + expect(hook.result.current.outbox).toMatchObject([ + { clientMessageId: 'op-held', lastFailure: { code: 'agent_session_checkpoint_stale' } } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx deleted file mode 100644 index 27f38b8d4959..000000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-release.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -// @vitest-environment happy-dom - -// A message the chat said was not sent goes out again on a new owner only on a host known to be -// older. A host whose capability probe has not answered, or failed, says nothing about that, so -// the message waits for its Retry. - -import { act, cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => { - const regained: (() => void)[] = [] - return { call: vi.fn(), probe: vi.fn(), regained } -}) - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call -})) -vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ - ...(await importOriginal>()), - runtimeEnvironmentSupportsCapability: mocks.probe -})) -vi.mock('@/runtime/runtime-host-contact-regained', () => ({ - subscribeRuntimeHostContactRegained: (_environmentId: string, listener: () => void) => { - mocks.regained.push(listener) - return () => {} - } -})) - -import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { writeOutbox } from './structured-agent-session-outbox-storage' -import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' - -type SendRequest = { envelope?: { clientOperationId?: string } } - -function requestId(params: SendRequest | undefined): string { - return String(params?.envelope?.clientOperationId) -} - -function sentIds(): string[] { - return mocks.call.mock.calls.map((call) => requestId(call[2])) -} - -async function settle(): Promise { - await act(() => new Promise((resolve) => setTimeout(resolve, 50))) -} - -function mount(fence: number) { - return renderHook( - ({ fence: current }: { fence: number }) => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: { kind: 'environment', environmentId: 'env-1' }, - fence: current, - submissions: [] - }), - { initialProps: { fence } } - ) -} - -function refusal(code: string) { - return { ok: false, refusal: { code, message: code } } -} - -async function refusedOnce(code: string) { - mocks.call.mockResolvedValue(refusal(code)) - const hook = mount(1) - await settle() - act(() => void hook.result.current.send('hello')) - await settle() - expect(sentIds()).toHaveLength(1) - expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() - return hook -} - -describe('a refused message when the chat gets a new owner', () => { - beforeEach(() => { - localStorage.clear() - mocks.call.mockReset() - mocks.probe.mockReset() - mocks.regained.length = 0 - }) - - afterEach(() => cleanup()) - - it('is not sent on a current host whose capability probe fails after the owner moved', async () => { - mocks.probe.mockResolvedValue(true) - const hook = await refusedOnce('agent_session_checkpoint_stale') - hook.rerender({ fence: 2 }) - await settle() - mocks.probe.mockRejectedValueOnce(new Error('status timeout')) - act(() => mocks.regained.forEach((listener) => listener())) - await settle() - expect(sentIds()).toHaveLength(1) - expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() - }) - - it('is not sent while the capability probe has not answered', async () => { - mocks.probe.mockReturnValue(new Promise(() => {})) - const hook = await refusedOnce('agent_session_checkpoint_stale') - hook.rerender({ fence: 2 }) - await settle() - expect(sentIds()).toHaveLength(1) - }) - - it.each([ - 'agent_session_owner_restart_failed', - 'agent_session_checkpoint_stale', - 'agent_session_conflict', - 'agent_session_ownership_unknown', - 'execution_owner_reconciling' - ] as const)('is sent again, same id, on a host known to be older, after %s', async (code) => { - mocks.probe.mockResolvedValue(false) - // Held under an id already sent once, which a settled refusal would otherwise have replaced. - writeOutbox('session-1', [ - { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'op-held', - sessionId: 'session-1', - text: 'hello', - attachments: [], - queuedAt: 1 - }), - lastAttemptAt: 2, - lastFailure: { kind: 'refused', code } - } - ]) - mocks.call.mockResolvedValue({ ok: true, replayed: false, fence: 2, value: { queued: {} } }) - const hook = mount(1) - await settle() - expect(sentIds()).toEqual([]) - hook.rerender({ fence: 2 }) - await settle() - expect(sentIds()).toEqual(['op-held']) - }) - - // A new owner answers none of these, so a copy the user retyped is never joined by the original. - it.each(['agent_session_journal_unreadable', 'agent_session_operation_capacity'])( - 'waits for its Retry on a host known to be older, after %s', - async (code) => { - mocks.probe.mockResolvedValue(false) - const hook = await refusedOnce(code) - hook.rerender({ fence: 2 }) - await settle() - expect(sentIds()).toHaveLength(1) - expect(hook.result.current.outbox[0]?.lastFailure).toBeDefined() - } - ) - - it('waits for its Retry on a host known to be older when the send never reached it', async () => { - mocks.probe.mockResolvedValue(false) - mocks.call.mockRejectedValueOnce(new Error('send failed')) - const hook = mount(1) - await settle() - act(() => void hook.result.current.send('hello')) - await settle() - expect(hook.result.current.outbox[0]?.lastFailure).toEqual({ kind: 'failed' }) - hook.rerender({ fence: 2 }) - await settle() - expect(sentIds()).toHaveLength(1) - }) -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 4f8f001b14b7..50c03335de97 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -396,7 +396,8 @@ function submission( } } -// An older host: it restarts the agent inside the send, so a new fence is its word to send again. +// An older host restarts the agent inside the send. A send it refused was shown as not sent, so it +// waits for the user's Retry even once the agent has a new owner, as on every host. describe('a send refused while its agent restarted', () => { beforeEach(() => { vi.clearAllMocks() @@ -409,7 +410,7 @@ describe('a send refused while its agent restarted', () => { }) // The live order: the restart takes seconds, so the journal settles the resend before its reply. - it('leaves no error behind once a send refused before an agent restart is delivered', async () => { + it('waits for its Retry across an agent restart, and leaves no error once that Retry lands', async () => { mocks.call .mockResolvedValueOnce({ ok: false, @@ -435,8 +436,13 @@ describe('a send refused while its agent restarted', () => { expect(shownFailure(result.current.outbox[0])).toBe('Your message was not sent.') ) - // The pane learns the new owner and sends the same message again. + // The pane learns the new owner; the message still waits for its Retry. rerender({ fence: 3, submissions: [] }) + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + expect(mocks.call).toHaveBeenCalledTimes(1) + expect(shownFailure(result.current.outbox[0])).toBe('Your message was not sent.') + + act(() => result.current.retry(result.current.outbox[0]!.clientMessageId)) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) expect(result.current.outbox[0]).toMatchObject({ state: 'dispatching' }) expect(shownFailure(result.current.outbox[0])).toBeUndefined() @@ -446,6 +452,7 @@ describe('a send refused while its agent restarted', () => { rerender({ fence: 3, submissions: [submission(id, 'accepted')] }) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) expect(result.current.error).toBeNull() + expect(mocks.call).toHaveBeenCalledTimes(2) }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index 01111882ed02..ea33f1449a75 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -29,6 +29,8 @@ import { writeOutbox } from './structured-agent-session-outbox-storage' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' const SESSION = 'session-1' +// Stable, as the view passes it: a new object each render would re-run the owner-change requeue. +const LOCAL_TARGET = { kind: 'local' } as const const NEWER_ORCA_WORDS = 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' @@ -93,7 +95,7 @@ function mount(fence = 1) { ({ fence: current }: { fence: number }) => useStructuredAgentSessionOutbox({ sessionId: SESSION, - target: { kind: 'local' }, + target: LOCAL_TARGET, fence: current, submissions: [] }), @@ -251,8 +253,8 @@ describe('a message the host refused, across a relaunch', () => { }) }) -// An older host restarts the agent inside a send and refuses it unrecorded when that fails; a new -// fence while the chat is open is still its word to send again. A relaunch is not. +// An older host restarts the agent inside a send and refuses it unrecorded when that fails. The +// refused message was shown as not sent, so neither a relaunch nor a new owner sends it. describe('a message an older host refused, across a relaunch', () => { afterEach(() => { cleanup() @@ -278,7 +280,7 @@ describe('a message an older host refused, across a relaunch', () => { expect(mocks.call).toHaveBeenCalledTimes(1) }) - it('still sends it again when the fence moves while the chat is open', async () => { + it('is not sent when the fence moves while the chat is open; its Retry sends it once', async () => { hostAccepts() mocks.call.mockResolvedValueOnce(refusal('agent_session_checkpoint_stale')) const { result, rerender } = mount(1) @@ -286,7 +288,13 @@ describe('a message an older host refused, across a relaunch', () => { await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) rerender({ fence: 3 }) + await settle() + expect(mocks.call).toHaveBeenCalledTimes(1) + + act(() => result.current.retry(result.current.outbox[0]!.clientMessageId)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + await settle() + expect(sentIds()).toHaveLength(2) expect(sentIds()[1]).toBe(sentIds()[0]) }) }) @@ -388,7 +396,7 @@ describe('a message whose send could not be saved before it went out', () => { ({ fence }: { fence: number | null }) => useStructuredAgentSessionOutbox({ sessionId: SESSION, - target: { kind: 'local' }, + target: LOCAL_TARGET, fence, submissions: [] }), @@ -506,7 +514,7 @@ describe('a held message whose id expired', () => { const { result } = renderHook(() => useStructuredAgentSessionOutbox({ sessionId: SESSION, - target: { kind: 'local' }, + target: LOCAL_TARGET, fence: 1, submissions: [], composerScopeKey: 'pane-1' 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 30784d49c86d..8204b8b51b48 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 @@ -6,8 +6,7 @@ import { writeOutbox } from './structured-agent-session-outbox-storage' const UNCONFIRMED_PROBE_BASE_DELAY_MS = 1_000 /** No attempt ceiling: a transport outage outlives any fixed budget, and giving up * restores the wedge this fixes. Growth caps the rate at one status query per 16s. - * A refusal that blocks the head still ends probing until a manual Retry (or, on an older - * host, a fence change), because the entry leaves `unconfirmed`. */ + * A refusal still ends probing until a manual Retry, because the entry leaves `unconfirmed`. */ const UNCONFIRMED_PROBE_MAX_DELAY_MS = 16_000 /** Re-queues the entry holding the outbox in `unconfirmed`, with backoff, until the journal answers it. */ 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 5972a1aec0b3..5b7f029024f6 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 @@ -18,7 +18,6 @@ import { readOutbox, writeOutbox } from './structured-agent-session-outbox-stora import { dispatchStructuredAgentSessionOutboxEntry, readMountedStructuredAgentSessionOutbox, - releaseStructuredAgentSessionRefusalsForNewOwner, requeueInterruptedStructuredAgentSessionDispatches } from './structured-agent-session-outbox-dispatch' import { getStructuredAgentLaunchPromptDispatch } from '@/lib/structured-agent-session-launch-prompt' @@ -71,7 +70,7 @@ export function useStructuredAgentSessionOutbox(args: { target } = args const { capability: queueCapability, enabled: queueEnabled } = queueDelivery - // What resends, releases and drops a send in flight besides a Retry or a new send; see the hook. + // What resends and drops a send in flight besides a Retry or a new send; see the hook. const owner = useStructuredAgentSessionOutboxOwnerChange(target, fence) const restoreWithdrawn = useStructuredAgentSessionWithdrawnRestore(sessionId, composerScopeKey) const [outbox, setOutbox] = useState(() => @@ -83,9 +82,6 @@ export function useStructuredAgentSessionOutbox(args: { // "which entry" must never disagree: the journal can settle the tail while the head moves. const inFlightIdRef = useRef(null) const dispatchGenerationRef = useRef(0) - // The last owner an older host's refusals here waited on; a later one is a new owner, a mount or - // a session change is not. - const refusalOwnerRef = useRef({ sessionId, owner: owner.refusalOwner }) const [error, setError] = useState(null) const [errorSession, setErrorSession] = useState(sessionId) // Render-time reset (react.dev: adjusting state when a prop changes), so the @@ -122,25 +118,6 @@ export function useStructuredAgentSessionOutbox(args: { } }, [owner.fenceRef, owner.ownerChange, sessionId, target]) - useEffect(() => { - const last = refusalOwnerRef.current - // A host not heard from says nothing about its owner, so the last known one stands. - if (owner.refusalOwner === null) { - return - } - refusalOwnerRef.current = { sessionId, owner: owner.refusalOwner } - if (last.sessionId !== sessionId || last.owner === null || last.owner === owner.refusalOwner) { - return - } - const current = outboxRef.current - const next = releaseStructuredAgentSessionRefusalsForNewOwner(current) - if (next.some((entry, index) => entry !== current[index])) { - outboxRef.current = next - setOutbox(next) - writeOutbox(sessionId, next) - } - }, [owner.refusalOwner, sessionId]) - useEffect(() => { const current = outboxRef.current const hostOwns = new Set([ @@ -198,7 +175,10 @@ export function useStructuredAgentSessionOutbox(args: { ) useEffect(() => { - const head = outbox[0] + // The latest list, not this render's: an effect earlier in this commit (an owner change's + // requeue, a reconcile) may have written a newer one that staging must keep. + const current = outboxRef.current + const head = current[0] if (!head || head.sessionId !== sessionId) { return } @@ -209,7 +189,7 @@ export function useStructuredAgentSessionOutbox(args: { } // A launch settlement dispatches outside this hook's single-flight, so while its send is up // nothing else may go out beside it and race it for the host's arrival order. - const launching = outbox.find((entry) => entry.source === 'launch') + const launching = current.find((entry) => entry.source === 'launch') const launchDispatch = launching ? getStructuredAgentLaunchPromptDispatch( launching.sessionId, @@ -229,7 +209,7 @@ export function useStructuredAgentSessionOutbox(args: { void launchDispatch.then(mirrorPersisted) return } - const admission = admitStructuredAgentSessionOutboxEntry(outbox) + const admission = admitStructuredAgentSessionOutboxEntry(current) if (admission.state !== 'dispatch' || fence === null || inFlightIdRef.current !== null) { return } @@ -252,7 +232,7 @@ export function useStructuredAgentSessionOutbox(args: { const dispatchGeneration = dispatchGenerationRef.current const dispatch = dispatchStructuredAgentSessionOutboxEntry({ next: attempt.wire, - entries: outbox.map((entry) => + entries: current.map((entry) => entry.clientMessageId === next.clientMessageId ? attempt.stored : entry ), sessionId, @@ -335,8 +315,6 @@ export function useStructuredAgentSessionOutbox(args: { return { outbox, error, - /** A refusal the host gave for want of an owner goes out again on its next one. */ - resendsOnNewOwner: owner.olderHost, send, retry, withdrawUnsent diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 5bd1cce89182..29abbc676770 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -213,7 +213,6 @@ export function useStructuredAgentSession(args: { loadOlder, prompts, outbox, - resendsOnNewOwner: outboxController.resendsOnNewOwner, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, // A message typed during a command queues behind it on the host. diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 704338fae1cb..e5ad0c295f37 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2861,7 +2861,6 @@ "recordFailed": "Orca couldn't record it in this chat's history.", "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", - "resendsOnRestart": "Orca will send it again when the agent restarts.", "restartFailed": "The agent couldn't restart.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f9b05ecb4704..d395e8660efc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17538,7 +17538,6 @@ "notDoneCommand": "The command didn't run.", "notDoneGoal": "The goal wasn't changed.", "restartFailed": "The agent couldn't restart.", - "resendsOnRestart": "Orca will send it again when the agent restarts.", "capacity": "Orca has received too many requests in the last day.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", "questionChanged": "This question was already answered or has changed.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6d7846811e23..006f1b4570f4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14976,7 +14976,6 @@ "notDoneCommand": "El comando no se ejecutó.", "notDoneGoal": "El objetivo no se cambió.", "restartFailed": "El agente no pudo reiniciarse.", - "resendsOnRestart": "Orca lo volverá a enviar cuando el agente se reinicie.", "capacity": "Orca ha recibido demasiadas solicitudes en el último día.", "outcomeUnknown": "Orca no pudo confirmar qué pasó. Revise el chat.", "questionChanged": "Esta pregunta ya se respondió o ha cambiado.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ad6ad62669ae..7c2b182aa8bd 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17450,7 +17450,6 @@ "notDoneCommand": "La commande n'a pas été exécutée.", "notDoneGoal": "L'objectif n'a pas été modifié.", "restartFailed": "L'agent n'a pas pu redémarrer.", - "resendsOnRestart": "Orca le renverra quand l'agent redémarrera.", "capacity": "Orca a reçu trop de requêtes au cours de la dernière journée.", "outcomeUnknown": "Orca n'a pas pu confirmer ce qui s'est passé. Vérifiez le chat.", "questionChanged": "Cette question a déjà reçu une réponse ou a changé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5ac62eec5563..bb5ba4032b95 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17386,7 +17386,6 @@ "notDoneCommand": "コマンドは実行されませんでした。", "notDoneGoal": "目標は変更されませんでした。", "restartFailed": "Agent を再起動できませんでした。", - "resendsOnRestart": "Agent が再起動したら、Orca が再送信します。", "capacity": "Orca が過去 1 日に受け取ったリクエストが多すぎます。", "outcomeUnknown": "Orca は何が起きたかを確認できませんでした。チャットを確認してください。", "questionChanged": "この質問はすでに回答済みか、変更されています。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2e731a2f0194..2246a60b237c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17386,7 +17386,6 @@ "notDoneCommand": "명령이 실행되지 않았습니다.", "notDoneGoal": "목표가 변경되지 않았습니다.", "restartFailed": "에이전트를 다시 시작하지 못했습니다.", - "resendsOnRestart": "에이전트가 다시 시작되면 Orca가 다시 보냅니다.", "capacity": "Orca가 지난 하루 동안 너무 많은 요청을 받았습니다.", "outcomeUnknown": "Orca가 무슨 일이 있었는지 확인하지 못했습니다. 채팅을 확인하세요.", "questionChanged": "이 질문은 이미 답변되었거나 변경되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 22b1dd1e4af4..0886a1a183e8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17351,7 +17351,6 @@ "notDoneCommand": "命令未运行。", "notDoneGoal": "目标未更改。", "restartFailed": "智能体无法重新启动。", - "resendsOnRestart": "智能体重新启动后,Orca 会重新发送。", "capacity": "Orca 在过去一天内收到的请求过多。", "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", "questionChanged": "此问题已被回答或已更改。", diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx index e9da06acee1f..acdfdbb03233 100644 --- a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.test.tsx @@ -11,7 +11,7 @@ vi.mock('./runtime-rpc-client', () => ({ })) import { setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' -import { useStructuredAgentSessionHostAcceptedSendState } from './structured-agent-session-accepted-send-capability' +import { useStructuredAgentSessionHostAcceptsSend } from './structured-agent-session-accepted-send-capability' afterEach(() => { setLocalRuntimeCapabilitiesForTests(null) @@ -21,41 +21,31 @@ afterEach(() => { describe('whether a host accepts a send before any agent has it', () => { it('reads the local host from its advertised capabilities', () => { setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) - const { result } = renderHook(() => - useStructuredAgentSessionHostAcceptedSendState({ kind: 'local' }) - ) - expect(result.current).toBe('supported') + const { result } = renderHook(() => useStructuredAgentSessionHostAcceptsSend({ kind: 'local' })) + expect(result.current).toBe(true) }) it('treats a local host that does not advertise it as an older one', () => { setLocalRuntimeCapabilitiesForTests([]) - const { result } = renderHook(() => - useStructuredAgentSessionHostAcceptedSendState({ kind: 'local' }) - ) - expect(result.current).toBe('unsupported') + const { result } = renderHook(() => useStructuredAgentSessionHostAcceptsSend({ kind: 'local' })) + expect(result.current).toBe(false) }) - it('asks a remote host, and reads a failed probe as not yet known', async () => { + it('asks a remote host, and reads a failed probe as an older host', async () => { mocks.supports.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('offline')) const accepts = renderHook(() => - useStructuredAgentSessionHostAcceptedSendState({ - kind: 'environment', - environmentId: 'env-1' - }) + useStructuredAgentSessionHostAcceptsSend({ kind: 'environment', environmentId: 'env-1' }) ) - await waitFor(() => expect(accepts.result.current).toBe('supported')) + await waitFor(() => expect(accepts.result.current).toBe(true)) expect(mocks.supports).toHaveBeenCalledWith( 'env-1', AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ) const offline = renderHook(() => - useStructuredAgentSessionHostAcceptedSendState({ - kind: 'environment', - environmentId: 'env-2' - }) + useStructuredAgentSessionHostAcceptsSend({ kind: 'environment', environmentId: 'env-2' }) ) await waitFor(() => expect(mocks.supports).toHaveBeenCalledTimes(2)) - expect(offline.result.current).toBe('unknown') + expect(offline.result.current).toBe(false) }) }) diff --git a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts index 5c979d999ab6..f90e50fcaa22 100644 --- a/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-accepted-send-capability.ts @@ -3,57 +3,45 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY } from '../../../shared/ import type { RuntimeClientTarget } from './runtime-client-target' import { structuredAgentSessionHostKey, - useStructuredAgentSessionHostCapabilityState, - type StructuredAgentSessionHostCapabilityState + useStructuredAgentSessionHostCapability } from './structured-agent-session-host-capability' /** * Whether the host answers a send at acceptance and never refuses one because its agent could not * start. Such a host records every send before it starts anything, so nothing about a moved fence - * calls for a resend. `unknown` until the host has said so, and after a failed probe. + * calls for a resend. False until the host has said so: an older host is handled as it always was. */ -export function useStructuredAgentSessionHostAcceptedSendState( - target: RuntimeClientTarget -): StructuredAgentSessionHostCapabilityState { - return useStructuredAgentSessionHostCapabilityState( +export function useStructuredAgentSessionHostAcceptsSend(target: RuntimeClientTarget): boolean { + return useStructuredAgentSessionHostCapability( target, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ) } /** - * When an outbox treats its owner as changed. An older host restarts the agent inside the send and - * refuses it, unrecorded, when that fails, so a new fence is its only word that another try may - * land. A host that accepts first records every send before it starts anything, so a moved fence - * means nothing there, and only a Retry or a new send goes out. + * When an outbox treats its owner as changed: resending a send in flight under the same id and + * dropping that send's answer. An older host restarts the agent inside the send, so a new fence is + * its only word that a send it never answered may land with the new owner. A send it refused was + * shown as not sent and waits for its Retry, as on every host. A host that accepts first records + * every send before it starts anything, so a moved fence means nothing there. */ export function useStructuredAgentSessionOutboxOwnerChange( target: RuntimeClientTarget, fence: number | null ): { - /** Resends a send in flight under the same id and drops its answer; any host not known to - * accept first is handled as an older one, as nothing it says is lost that way. */ ownerChange: number | null - /** Releases a message the host refused, which was shown as not sent: only a host known to be - * older, so one not yet heard from never sends it without its Retry. */ - refusalOwner: number | null - /** Whether the host is known to be older, so a refusal it gave for want of an owner goes out - * again on its next one. */ - olderHost: boolean attached: boolean fenceRef: RefObject targetKey: string } { - const acceptedSend = useStructuredAgentSessionHostAcceptedSendState(target) + const acceptsSend = useStructuredAgentSessionHostAcceptsSend(target) // Read by effects that run on an owner change, not on every fence move. const fenceRef = useRef(fence) useLayoutEffect(() => { fenceRef.current = fence }, [fence]) return { - ownerChange: acceptedSend === 'supported' ? null : fence, - refusalOwner: acceptedSend === 'unsupported' ? fence : null, - olderHost: acceptedSend === 'unsupported', + ownerChange: acceptsSend ? null : fence, attached: fence !== null, fenceRef, targetKey: structuredAgentSessionHostKey(target) diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index e9a31ae7ab69..e5e6d011311b 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -25,7 +25,6 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { notDoneCommand: "The command didn't run.", notDoneGoal: "The goal wasn't changed.", restartFailed: "The agent couldn't restart.", - resendsOnRestart: 'Orca will send it again when the agent restarts.', capacity: 'Orca has received too many requests in the last day.', outcomeUnknown: "Orca couldn't confirm what happened. Check the chat.", questionChanged: 'This question was already answered or has changed.', diff --git a/src/shared/structured-agent-session-outbox-admission.ts b/src/shared/structured-agent-session-outbox-admission.ts index 2822603faa46..3d7e95889342 100644 --- a/src/shared/structured-agent-session-outbox-admission.ts +++ b/src/shared/structured-agent-session-outbox-admission.ts @@ -1,6 +1,5 @@ // Which outbox entry goes out next, and which ones wait for the user. -import type { AgentSessionWireRefusalCode } from './agent-session-wire' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' /** A send the user was told did not go through, with a Retry: only that Retry sends it again. Read @@ -11,27 +10,6 @@ export function structuredAgentSessionEntryHeldForRetry( return entry.state === 'queued' && entry.lastFailure !== undefined } -// What a host from before accepted sends refuses with while it restarts the chat's agent inside the -// send (its restart failed, or met an owner still settling); a new owner is what answers them. -const REFUSED_FOR_OWNER: ReadonlySet = new Set([ - 'agent_session_owner_restart_failed', - 'agent_session_checkpoint_stale', - 'agent_session_conflict', - 'agent_session_ownership_unknown', - 'execution_owner_reconciling' -]) - -/** On a host known to be older, a held message that host's next owner sends again. */ -export function structuredAgentSessionEntryWaitsForNewOwner( - entry: StructuredAgentSessionOutboxEntry -): boolean { - return ( - structuredAgentSessionEntryHeldForRetry(entry) && - entry.lastFailure?.kind === 'refused' && - REFUSED_FOR_OWNER.has(entry.lastFailure.code) - ) -} - export type StructuredAgentSessionOutboxAdmission = | { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry } | { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry } From 8bafbe84262d8f41b6d8358106db2abc98087acf Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:24:29 -0700 Subject: [PATCH 4/6] fix(native-chat): keep a not-sent message out of newer turns, and word its saved cause only when seen A message shown as not sent stays in the outbox and draws below every newer turn. It was an ordinary user row there, so it counted as the newest user row: while a new send waited for its turn to open, that turn's "Working for" clock drew under the old message, and once the turn ended an empty "Worked for" divider was left under it. The projection now marks such a bubble (held for its Retry, or rejected) as unsent; turn membership gives it no turn and never makes it the live one, on hosts that state turn scopes and on those that do not; and the transcript draws it after the live activity, as it draws a message waiting behind /compact. Mobile has no outbox, so its rows never carry the mark and its grouping is unchanged. A held message read back from storage repeated the cause it was saved with, which may no longer hold: "Update Orca to keep using them" after the user updated Orca. The outbox hook now remembers, in memory only, which messages failed while the chat was open; only those word their cause. Any other held message reads "Your message was not sent." with its Retry, and a Retry the cause still stops brings the full words back. An expired id keeps its words, since that cause cannot clear. --- ...iveChatMessageList.unsent-message.test.tsx | 216 ++++++++++++++++++ ...tiveChatStructuredSession.test-harness.tsx | 1 + .../NativeChatStructuredSession.tsx | 12 +- .../native-chat-transcript-slots.ts | 8 +- ...red-agent-session-delivery-notices.test.ts | 45 +++- ...ructured-agent-session-delivery-notices.ts | 14 +- ...ed-agent-session-outbox-admission.test.tsx | 3 +- ...ctured-agent-session-outbox-failed-here.ts | 63 +++++ ...gent-session-outbox-relaunch-hold.test.tsx | 36 ++- .../use-structured-agent-session-outbox.ts | 8 +- .../use-structured-agent-session.ts | 1 + .../native-chat-turn-membership.test.ts | 19 ++ src/shared/native-chat-turn-membership.ts | 40 +++- src/shared/native-chat-types.ts | 3 + ...ctured-agent-session-message-projection.ts | 4 + 15 files changed, 447 insertions(+), 26 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-failed-here.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx new file mode 100644 index 000000000000..2df11a22a10c --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom + +// A message shown as not sent stays in the outbox and draws below newer turns, after their live +// activity. It is in no turn: the newer turn's "Working for" clock and "Worked for" bar stay with +// that turn, never under it. + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +type Phase = 'in flight' | 'running' | 'done' + +const SEED = agentJournalSubmissionKey('seed') +const NEW = agentJournalSubmissionKey('new') + +function said(role: 'user' | 'assistant', text: string): AgentJournalItemBody { + return { kind: 'message', role, blocks: [{ type: 'text', text }] } +} + +function journal(phase: Phase, scoped: boolean): AgentJournalRenderItem[] { + const thread: AgentJournalTurnScope = { kind: 'thread' } + const row = ( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope + ): AgentJournalRenderItem => ({ + itemId, + revision: 0, + sequence: rows.length + 1, + observedAt: 1000 + rows.length, + body, + ...(scoped ? { turnScope } : {}) + }) + const rows: AgentJournalRenderItem[] = [] + rows.push(row(SEED, said('user', 'SEED PROMPT'), thread)) + rows.push( + row( + 'turn-seed', + { kind: 'turn', turnId: 'turn-seed', state: 'completed', userItemId: SEED }, + thread + ) + ) + rows.push( + row('seed-answer', said('assistant', 'SEED OK'), { kind: 'turn', turnItemId: 'turn-seed' }) + ) + rows.push(row(NEW, said('user', 'NEW PROMPT'), thread)) + if (phase !== 'in flight') { + rows.push( + row( + 'turn-new', + { + kind: 'turn', + turnId: 'turn-new', + state: phase === 'done' ? 'completed' : 'running', + userItemId: NEW + }, + thread + ) + ) + } + if (phase === 'done') { + rows.push( + row('new-answer', said('assistant', 'NEW OK'), { kind: 'turn', turnItemId: 'turn-new' }) + ) + } + return rows +} + +function submission( + id: string, + dispatchState: AgentJournalSubmission['dispatchState'] +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } +} + +function unsentEntry(kind: 'held' | 'rejected'): StructuredAgentSessionOutboxEntry { + const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'held', + sessionId: 'session-1', + text: 'HELD PROMPT', + attachments: [], + queuedAt: 500 + }) + return kind === 'held' + ? { + ...entry, + lastAttemptAt: 500, + lastFailure: { kind: 'refused', code: 'agent_session_journal_unreadable' } + } + : { ...entry, state: 'rejected', lastFailure: { kind: 'rejected', reason: null } } +} + +function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutboxEntry[]) { + const items = journal(phase, scoped) + const submissions = [ + submission('seed', 'accepted'), + submission('new', phase === 'done' ? 'accepted' : 'pending') + ] + const settledTurns: NativeChatSettledTurns = new Map([ + [SEED, { startedAt: 1, workedSeconds: 3 }], + ...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : []) + ]) + return ( + + ) +} + +/** The drawn sequence of the prompts, bars and live activity line, top to bottom. */ +function drawn(container: HTMLElement): string[] { + const out: string[] = [] + for (const element of container.querySelectorAll('*')) { + if (element.hasAttribute('data-native-chat-turn-activity')) { + out.push('ACTIVITY') + } else if (element.hasAttribute('data-native-chat-turn-status')) { + out.push( + element.getAttribute('data-native-chat-turn-status') === 'active' ? 'WORKING' : 'WORKED' + ) + } else if (element.childElementCount === 0 && (element.textContent ?? '').endsWith('PROMPT')) { + out.push(element.textContent ?? '') + } + } + return out +} + +describe.each([ + ['held for its Retry', 'held'], + ['rejected', 'rejected'] +] as const)('a message %s, below a newer turn', (_label, kind) => { + it.each([ + ['states each row turn', true], + ['states no turn scope', false] + ])( + 'keeps the newer turn bar with that turn while it runs and once done (host %s)', + (_host, scoped) => { + const outbox = [unsentEntry(kind)] + const { container, rerender } = render(list('in flight', scoped, outbox)) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'NEW PROMPT', + 'WORKING', + 'ACTIVITY', + 'HELD PROMPT' + ]) + rerender(list('running', scoped, outbox)) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'NEW PROMPT', + 'WORKING', + 'ACTIVITY', + 'HELD PROMPT' + ]) + rerender(list('done', scoped, outbox)) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'NEW PROMPT', + 'WORKED', + 'HELD PROMPT' + ]) + expect(container.textContent).toContain('Worked for 5s') + } + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index f8a5156dbdc9..cbd3b5204bd1 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -174,6 +174,7 @@ export function createStructuredSessionMocks() { loadOlder: mocks.loadOlder, prompts: mocks.promptItems, outbox: outbox.outbox, + failedHere: outbox.failedHere, submissions: mocks.submissions, send: outbox.send, retry: outbox.retry, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index cef7142094f5..a2265eba5cfd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -134,9 +134,17 @@ export function NativeChatStructuredSession( agentLabel, retryDelivery, rejectionRows, - startFailures + startFailures, + controller.failedHere ), - [controller.outbox, agentLabel, retryDelivery, rejectionRows, startFailures] + [ + controller.outbox, + agentLabel, + retryDelivery, + rejectionRows, + startFailures, + controller.failedHere + ] ) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 1e6dbabac58d..a6ae10cc2066 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -242,8 +242,8 @@ export function nativeChatSlotIndexOf( return slots.findIndex((slot) => slot.kind === 'message' && slot.message.id === messageId) } -/** Splits off the slots of messages waiting behind the live turn: they draw after its live - * activity, not inside it. */ +/** Splits off the slots of messages waiting behind the live turn, and of ones shown as not sent + * that the journal holds no place for: they draw after the live activity, not inside it. */ export function splitNativeChatSlotsWaitingBehindLiveTurn( slots: readonly NativeChatTranscriptSlot[], journalItems: readonly AgentJournalRenderItem[] | undefined @@ -253,7 +253,9 @@ export function splitNativeChatSlotsWaitingBehindLiveTurn( journalItems ) const isWaiting = (slot: NativeChatTranscriptSlot): boolean => - slot.kind === 'message' && waiting.has(slot.message.id) + slot.kind === 'message' && + (waiting.has(slot.message.id) || + (slot.message.unsent === true && slot.message.journalPosition === undefined)) return { slots: slots.filter((slot) => !isWaiting(slot)), waitingSlots: slots.filter(isWaiting) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index bda8f0c8da3d..5dde72621adb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -36,17 +36,21 @@ function entry( } } +const NOT_FAILED_HERE: ReadonlySet = new Set() + function texts( outbox: StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] = [], startFailures: readonly AgentSessionFailureFact[] = [] ): Record { + // Every failure seen while the chat was open, so each words its whole cause. const notices = structuredAgentSessionDeliveryNotices( outbox, 'Claude', () => {}, submissions, - startFailures + startFailures, + new Set(outbox.map((candidate) => candidate.clientMessageId)) ) return Object.fromEntries([...notices].map(([id, notice]) => [id, notice.text])) } @@ -72,7 +76,8 @@ describe('the notice on each message that did not go through', () => { 'Claude', retry, [], - [] + [], + NOT_FAILED_HERE ) expect([...notices.keys()]).toEqual([ @@ -173,7 +178,14 @@ describe('the notice on each message that did not go through', () => { [entry('stuck', { state: 'unconfirmed' }), entry('rejected', { state: 'rejected' })], [entry('held', { outlivedStop: true }), entry('rejected', { state: 'rejected' })] ]) { - const notices = structuredAgentSessionDeliveryNotices(outbox, 'Claude', retry, [], []) + const notices = structuredAgentSessionDeliveryNotices( + outbox, + 'Claude', + retry, + [], + [], + NOT_FAILED_HERE + ) expect(notices.get(agentJournalSubmissionKey('rejected'))).toEqual({ text: 'Message was not sent.' }) @@ -202,7 +214,8 @@ describe('the notice on each message that did not go through', () => { 'Claude', retry, [], - [] + [], + NOT_FAILED_HERE ) for (const id of ['refused', 'rejected', 'stuck']) { notices.get(agentJournalSubmissionKey(id))?.onRetry?.() @@ -411,7 +424,8 @@ describe('the notice on each message that did not go through', () => { 'Claude', vi.fn(), [], - [] + [], + NOT_FAILED_HERE ) expect(notices.get(agentJournalSubmissionKey('expired'))).toMatchObject({ text: "Orca couldn't confirm what happened. Check the chat." @@ -421,6 +435,27 @@ describe('the notice on each message that did not go through', () => { expect(notices.get(agentJournalSubmissionKey('fresh'))?.text).toBe('Your message was not sent.') }) + // A cause read back from storage may have cleared; a Retry it still stops brings it back. + it('words a held cause only where its failure was seen while the chat was open', () => { + const held = entry('held', { + lastAttemptAt: 1, + lastFailure: { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) + const words = (failedHere: ReadonlySet) => + structuredAgentSessionDeliveryNotices([held], 'Claude', vi.fn(), [], [], failedHere).get( + agentJournalSubmissionKey('held') + ) + expect(words(NOT_FAILED_HERE)).toMatchObject({ text: 'Your message was not sent.' }) + expect(words(NOT_FAILED_HERE)?.onRetry).toBeDefined() + expect(words(new Set(['held']))?.text).toBe( + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ) + }) + it('says nothing on a message that is only waiting its turn or on its way', () => { expect(texts([entry('queued'), entry('sending', { state: 'dispatching' })])).toEqual({}) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 67c69a26a511..6ad655e8f80c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -88,7 +88,8 @@ function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[] + startFailures: readonly AgentSessionFailureFact[], + failedHere: ReadonlySet ): string { // A send attempted before a Stop and then interrupted may already be with the host. const attemptedAcrossStop = @@ -109,6 +110,10 @@ function deliveryNoticeText( if (structuredAgentSessionEntryIdExpired(entry)) { return agentSessionWriteNoticeText(['outcomeUnknown']) } + // Its cause may have cleared since it was saved; a Retry it still stops brings the cause back. + if (structuredAgentSessionEntryHeldForRetry(entry) && !failedHere.has(entry.clientMessageId)) { + return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) + } if ( entry.state === 'rejected' && agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures) @@ -133,7 +138,9 @@ export function structuredAgentSessionDeliveryNotices( /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ - startFailures: readonly AgentSessionFailureFact[] + startFailures: readonly AgentSessionFailureFact[], + /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ + failedHere: ReadonlySet ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -156,7 +163,8 @@ export function structuredAgentSessionDeliveryNotices( entry, { agentName, retryControl }, rejected.get(entry.clientMessageId), - startFailures + startFailures, + failedHere ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index 27a1a055a158..8a215319ec68 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -283,7 +283,8 @@ describe('structured agent session outbox admission', () => { 'Claude', () => {}, [], - [] + [], + result.current.failedHere ) expect(notices.get(agentJournalSubmissionKey(c!.clientMessageId))?.onRetry).toBeDefined() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-failed-here.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-failed-here.ts new file mode 100644 index 000000000000..7e1858ce4aa7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-failed-here.ts @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' + +const NONE: ReadonlySet = new Set() + +/** + * The messages whose send failed or was refused while this chat was open, in memory only. Only + * they word their saved cause: one read back from storage may have outlived it (Orca was updated + * since, say), and its Retry, refused again if the cause holds, brings the words back. + */ +export function useStructuredAgentSessionOutboxFailedHere(sessionId: string): { + failedHere: ReadonlySet + /** Records the entries a send's answer gave a failure they did not carry before. */ + recordFailures: ( + before: readonly StructuredAgentSessionOutboxEntry[], + after: readonly StructuredAgentSessionOutboxEntry[] + ) => void + forget: (clientMessageId: string) => void +} { + const [recorded, setRecorded] = useState({ sessionId, ids: NONE }) + const recordFailures = useCallback( + ( + before: readonly StructuredAgentSessionOutboxEntry[], + after: readonly StructuredAgentSessionOutboxEntry[] + ): void => { + const previous = new Map(before.map((entry) => [entry.clientMessageId, entry.lastFailure])) + const failed = after.filter( + (entry) => + entry.lastFailure !== undefined && + previous.get(entry.clientMessageId) !== entry.lastFailure + ) + if (failed.length === 0) { + return + } + setRecorded((current) => ({ + sessionId, + ids: new Set([ + ...(current.sessionId === sessionId ? current.ids : NONE), + ...failed.map((entry) => entry.clientMessageId) + ]) + })) + }, + [sessionId] + ) + const forget = useCallback( + (clientMessageId: string): void => { + setRecorded((current) => { + if (current.sessionId !== sessionId || !current.ids.has(clientMessageId)) { + return current + } + const ids = new Set(current.ids) + ids.delete(clientMessageId) + return { sessionId, ids } + }) + }, + [sessionId] + ) + return { + failedHere: recorded.sessionId === sessionId ? recorded.ids : NONE, + recordFailures, + forget + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index ea33f1449a75..17c99f11af80 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -31,6 +31,8 @@ import { structuredAgentSessionDeliveryNotices } from './structured-agent-sessio const SESSION = 'session-1' // Stable, as the view passes it: a new object each render would re-run the owner-change requeue. const LOCAL_TARGET = { kind: 'local' } as const +// Read back from storage, the cause may have cleared since (the user updated Orca, say). +const NOT_SENT_WORDS = 'Your message was not sent.' const NEWER_ORCA_WORDS = 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' @@ -106,7 +108,14 @@ function mount(fence = 1) { type Outbox = ReturnType['result']['current'] function notices(outbox: Outbox) { - return structuredAgentSessionDeliveryNotices(outbox.outbox, 'Claude', outbox.retry, [], []) + return structuredAgentSessionDeliveryNotices( + outbox.outbox, + 'Claude', + outbox.retry, + [], + [], + outbox.failedHere + ) } function noticeFor(outbox: Outbox, clientMessageId: string) { @@ -167,7 +176,7 @@ describe('a message the host refused, across a relaunch', () => { await settle() expect(mocks.call).toHaveBeenCalledTimes(1) const notice = noticeFor(after.result.current, refusedId) - expect(notice?.text).toBe(NEWER_ORCA_WORDS) + expect(notice?.text).toBe(NOT_SENT_WORDS) expect(notice?.onRetry).toBeDefined() act(() => notice?.onRetry?.()) @@ -249,7 +258,28 @@ describe('a message the host refused, across a relaunch', () => { const { result } = mount() await settle() expect(mocks.call).not.toHaveBeenCalled() - expect(noticeFor(result.current, 'op-refused')?.text).toBe(NEWER_ORCA_WORDS) + expect(noticeFor(result.current, 'op-refused')?.text).toBe(NOT_SENT_WORDS) + }) + + it("words the cause again once a Retry is refused for it; a relaunch's row only says not sent", async () => { + mocks.call.mockResolvedValue(newerOrcaRefusal()) + const before = mount() + act(() => expect(before.result.current.send('hello')).toBe(true)) + await waitFor(() => expect(before.result.current.outbox[0]?.lastFailure).toBeDefined()) + const refusedId = sentIds()[0]! + before.unmount() + + // The chat's history is still from a newer Orca. + const after = mount() + await settle() + expect(noticeFor(after.result.current, refusedId)).toMatchObject({ text: NOT_SENT_WORDS }) + + act(() => noticeFor(after.result.current, refusedId)?.onRetry?.()) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await waitFor(() => + expect(noticeFor(after.result.current, refusedId)?.text).toBe(NEWER_ORCA_WORDS) + ) + expect(noticeFor(after.result.current, refusedId)?.onRetry).toBeDefined() }) }) 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 5b7f029024f6..1067c2507e1c 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 @@ -35,6 +35,7 @@ import { type StructuredAgentSessionQueueDelivery } from '../../../../shared/structured-agent-session-outbox-delivery' import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' +import { useStructuredAgentSessionOutboxFailedHere } from './use-structured-agent-session-outbox-failed-here' const NO_QUEUE_DELIVERY: StructuredAgentSessionQueueDelivery = { capability: 'unsupported', @@ -83,6 +84,8 @@ export function useStructuredAgentSessionOutbox(args: { const inFlightIdRef = useRef(null) const dispatchGenerationRef = useRef(0) const [error, setError] = useState(null) + const { failedHere, recordFailures, forget } = + useStructuredAgentSessionOutboxFailedHere(sessionId) const [errorSession, setErrorSession] = useState(sessionId) // Render-time reset (react.dev: adjusting state when a prop changes), so the // old session's banner neither flashes for a frame nor resurrects on return. @@ -167,11 +170,12 @@ export function useStructuredAgentSessionOutbox(args: { // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null setError(disposition.error) + recordFailures(outboxRef.current, disposition.entries) outboxRef.current = disposition.entries setOutbox(disposition.entries) writeOutbox(sessionId, disposition.entries) }, - [sessionId] + [recordFailures, sessionId] ) useEffect(() => { @@ -302,6 +306,7 @@ export function useStructuredAgentSessionOutbox(args: { const retry = (clientMessageId: string): void => { setError(null) + forget(clientMessageId) retryStructuredAgentSessionOutboxEntry({ clientMessageId, sessionId, @@ -315,6 +320,7 @@ export function useStructuredAgentSessionOutbox(args: { return { outbox, error, + failedHere, send, retry, withdrawUnsent diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 29abbc676770..d63a5263baa3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -213,6 +213,7 @@ export function useStructuredAgentSession(args: { loadOlder, prompts, outbox, + failedHere: outboxController.failedHere, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, // A message typed during a command queues behind it on the host. diff --git a/src/shared/native-chat-turn-membership.test.ts b/src/shared/native-chat-turn-membership.test.ts index 53963b73ef06..69e08b047938 100644 --- a/src/shared/native-chat-turn-membership.test.ts +++ b/src/shared/native-chat-turn-membership.test.ts @@ -239,4 +239,23 @@ describe('a message the provider answered after the running turn', () => { ]) expect(membership.liveTurnKey).toBe('B') }) + + // A message shown as not sent stays in the outbox, below every journal row. + it.each([ + ['states each row turn', THREAD], + ['states no turn scope', null] + ] as const)( + 'puts a message shown as not sent in no turn, never the live one (host %s)', + (_host, scope) => { + const items = [user('u1', scope), turn('t1', 'u1', scope), user('u2', scope)] + const messages = [ + ...rows(items), + { id: 'held', role: 'user' as const, unsent: true as const } + ] + const membership = nativeChatTurnMembership(messages, { items, submissions: [] }) + expect(membership.turnKeys).toEqual(['u1', 'u2', undefined]) + // The send whose turn has not opened yet is live, not the message below it. + expect(membership.liveTurnKey).toBe('u2') + } + ) }) diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index f78303546ba5..f00923996c40 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -125,29 +125,36 @@ export type NativeChatTurnMembership = { drawOrder: readonly number[] | null } +type NativeChatTurnMember = { id: string; role: NativeChatRole; unsent?: true } + /** * Places each row in its turn. A user entry that anchors a turn, or is scoped to none, keys * itself; one delivered into a running turn (a steer) takes that turn's key. A host that states no * scope is read by journal order instead (`nativeChatJournalOrderTurnKeys`). `opensTurn` narrows - * which rows may key themselves, for rows that interleave a subagent's with the conversation's. + * which rows may key themselves, for rows that interleave a subagent's with the conversation's. A + * message shown as not sent is in no turn, so it is never the live one. */ export function nativeChatTurnMembership( - messages: readonly { id: string; role: NativeChatRole }[], + messages: readonly NativeChatTurnMember[], journal?: NativeChatTurnJournal | null, opensTurn: NativeChatOpensTurn = nativeChatUserRowOpensTurn ): NativeChatTurnMembership { + const opens: NativeChatOpensTurn = (message) => !isUnsent(message) && opensTurn(message) if (!journal) { - const turnKeys = nativeChatRowTurnKeys(messages, null, opensTurn) + const turnKeys = withoutUnsent(messages, nativeChatRowTurnKeys(messages, null, opens)) return { turnKeys, liveTurnKey: newestUserTurnKey(messages, turnKeys), drawOrder: null } } const anchors = structuredAgentTurnAnchors(journal.items, journal.submissions) const running = liveStructuredAgentSessionTurnScope(journal.items) if (!hostStatesTurnScopes(journal.items)) { const recordKeys = namedRecordKeys(journal.items, anchors) - const turnKeys = nativeChatRowTurnKeys( + const turnKeys = withoutUnsent( messages, - nativeChatJournalOrderTurnKeys(journal.items, recordKeys), - opensTurn + nativeChatRowTurnKeys( + messages, + nativeChatJournalOrderTurnKeys(journal.items, recordKeys), + opens + ) ) const runningNamed = running.kind === 'turn' ? recordKeys.get(running.turnItemId) : null return { @@ -160,6 +167,9 @@ export function nativeChatTurnMembership( const anchoring = new Set(anchors.values()) const scopes = new Map(journal.items.map((item) => [item.itemId, item.turnScope])) const turnKeys = messages.map((message) => { + if (message.unsent === true) { + return undefined + } const scope = scopes.get(message.id) const turnKey = scope?.kind === 'turn' && scope.turnItemId ? anchors.get(scope.turnItemId) : undefined @@ -175,6 +185,18 @@ export function nativeChatTurnMembership( } } +function isUnsent(message: { id: string; role: NativeChatRole }): boolean { + return 'unsent' in message && message.unsent === true +} + +/** An unsent row neither keys a turn nor inherits the one before it. */ +function withoutUnsent( + messages: readonly NativeChatTurnMember[], + turnKeys: (string | undefined)[] +): (string | undefined)[] { + return turnKeys.map((turnKey, index) => (messages[index]?.unsent === true ? undefined : turnKey)) +} + /** Each root record's anchor, or null for a record that names no opener (only older hosts write * one): such a record owns no rows, which keep their position. */ function namedRecordKeys( @@ -225,9 +247,11 @@ function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { } function newestUserTurnKey( - messages: readonly { role: NativeChatRole }[], + messages: readonly NativeChatTurnMember[], turnKeys: readonly (string | undefined)[] ): string | undefined { - const index = messages.findLastIndex((message) => message.role === 'user') + const index = messages.findLastIndex( + (message) => message.role === 'user' && message.unsent !== true + ) return index === -1 ? undefined : turnKeys[index] } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 6d9d344f5069..db7b7024cdc1 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -212,6 +212,9 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { sentAs?: AgentJournalMessageSendMode /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ queued?: true + /** Shown as not sent, waiting for the user's Retry: in no turn, so a newer turn's bar and clock + * never land on it, and drawn after the conversation. */ + unsent?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ journalPosition?: AgentJournalPosition diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 308728c19c38..5515b6478a22 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -5,6 +5,7 @@ import { isQueuedAgentJournalSubmission } from './agent-session-queued-submissio import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import type { NativeChatMessage } from './native-chat-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' @@ -62,6 +63,9 @@ export function projectStructuredAgentSessionMessages( source: 'transcript', timestamp: entry.queuedAt, blocks: entry.body.blocks, + ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) + ? { unsent: true as const } + : {}), // A send the journal recorded before refusing it keeps its place there. ...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {}) } From be50f6b4ada643ba40ee43968ee7fc5f2cdf9343 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:54:53 -0700 Subject: [PATCH 5/6] fix(native-chat): follow the bottom and light a tick for a chat whose only rows are not sent A message shown as not sent draws after the windowed transcript. When it was the only row, the windowed list was empty, and following the bottom or "Jump to latest" asked the virtualizer for an end it computes from its own rows: the top. It now scrolls to the container's own bottom when no row is windowed. A rejected send the journal recorded keeps its place but opens no turn, so the rail lit no tick when it was the row being read. A user row in no turn now lights its own tick. Also pins that a refusal seen while the chat is open reaches the rendered notice in full, and reads only "not sent" after the chat is reopened until a Retry is refused again. --- ...tiveChatStructuredSessionDelivery.test.tsx | 42 +++++++++++++++++++ .../native-chat-active-rail-item.test.ts | 27 ++++++++++++ .../native-chat-active-rail-item.ts | 14 +++++-- ...ve-chat-transcript-window.options.test.tsx | 26 ++++++++++++ .../use-native-chat-transcript-window.ts | 17 ++++++-- 5 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 29eb0132385d..460df23ef8b3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -79,6 +79,7 @@ vi.mock('./use-structured-agent-session', async () => { loadOlder: vi.fn(), prompts: mocks.promptItems, outbox: outbox.outbox, + failedHere: outbox.failedHere, submissions: mocks.submissions, send: outbox.send, retry: outbox.retry, @@ -182,6 +183,7 @@ vi.mock('./NativeChatQuestionCard', () => ({ })) import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' describe('NativeChatStructuredSession delivery', () => { afterEach(() => { @@ -767,4 +769,44 @@ describe('NativeChatStructuredSession delivery', () => { vi.useRealTimers() } }, 30000) + + // A cause seen while the chat was open is worded in full; one read back after the chat is + // reopened may have cleared, until a Retry it still stops brings it back. + it('words a refusal seen here in full, and after a reopen only once its Retry is refused', async () => { + const newerOrca = + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + mocks.mode = 'outbox' + mocks.call.mockResolvedValue({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'newer', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) + const view = (): React.JSX.Element => ( + + ) + const first = render(view()) + // The composer's own enqueue. + act(() => { + expect(appendStructuredAgentSessionOutboxMessage('session-held', 'hello')).not.toBeNull() + }) + await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy()) + first.unmount() + + render(view()) + await waitFor(() => expect(screen.getByText('Your message was not sent.')).toBeTruthy()) + expect(mocks.call).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: /Retry/ })) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(screen.getByText(newerOrca)).toBeTruthy()) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts index e30f34dabd99..72e4898ce04c 100644 --- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.test.ts @@ -122,4 +122,31 @@ describe('active rail item', () => { }) ).toBe('u2') }) + + // A rejected send the journal recorded keeps its place but opens no turn. + it('lights a prompt in no turn by its own id', () => { + const slots: NativeChatRailSlot[] = [ + ...TURNS, + { turnKey: undefined, message: { id: 'rejected', role: 'user' } } + ] + expect( + findActiveNativeChatRailItem({ + slots, + virtualItems: rows(11), + scrollTop: 800, + clientHeight: VIEWPORT, + scrollHeight: 1100, + previousActiveId: null + }) + ).toBe('rejected') + expect( + findActiveNativeChatRailItem({ + slots, + virtualItems: rows(11), + scrollTop: 1010, + ...MID_SCROLL, + scrollHeight: 2000 + }) + ).toBe('rejected') + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts index f96049781597..5ca6c1584661 100644 --- a/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts +++ b/src/renderer/src/components/native-chat/native-chat-active-rail-item.ts @@ -24,9 +24,15 @@ export type NativeChatRailVirtualItem = { end: number } -/** Only the field the rail reads, so a test needs no slot builder. */ +/** Only the fields the rail reads, so a test needs no slot builder. */ export type NativeChatRailSlot = { turnKey: string | undefined + /** A user row in no turn (one shown as not sent) lights its own tick. */ + message?: { id: string; role: string } +} + +function railTickOf(slot: NativeChatRailSlot | undefined): string | null { + return slot?.turnKey ?? (slot?.message?.role === 'user' ? slot.message.id : null) } export function findActiveNativeChatRailItem({ @@ -53,7 +59,7 @@ export function findActiveNativeChatRailItem({ const atBottom = scrollHeight - clientHeight - scrollTop <= NATIVE_CHAT_BOTTOM_THRESHOLD_PX if (atBottom) { const last = virtualItems.at(-1) - return last === undefined ? previousActiveId : (slots[last.index]?.turnKey ?? null) + return last === undefined ? previousActiveId : railTickOf(slots[last.index]) } let fold: NativeChatRailVirtualItem | undefined @@ -65,12 +71,12 @@ export function findActiveNativeChatRailItem({ // Scrolled above everything the window holds: the first windowed row is the // nearest thing to the fold. if (fold === undefined) { - return slots[virtualItems[0]?.index ?? -1]?.turnKey ?? null + return railTickOf(slots[virtualItems[0]?.index ?? -1]) } // The window lags the scroll by a commit, so a fold past every row it holds is // a stale read, not an answer. Holding the previous tick beats blanking one. if (fold.end <= scrollTop) { return previousActiveId } - return slots[fold.index]?.turnKey ?? null + return railTickOf(slots[fold.index]) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx index dc38fa9ec768..0a8f6347ce9d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -220,4 +220,30 @@ describe('native chat transcript virtualizer contract', () => { expect(virtualizerMock.scrollToOffset).not.toHaveBeenCalled() }) + + // Rows drawn after the window (a message shown as not sent) still fill the container. + it('follows the bottom of the container when no row is windowed', () => { + const container = document.createElement('div') + Object.defineProperty(container, 'scrollHeight', { configurable: true, value: 2000 }) + virtualizerMock.scrollElement.current = container + const noSlots: NativeChatMessageSlot[] = [] + const { result, rerender } = renderHook( + ({ slots }) => + useNativeChatTranscriptWindow({ + scrollRef: { current: container }, + slots, + isVisible: true, + revealIndex: -1 + }), + { initialProps: { slots: noSlots } } + ) + + result.current.scrollToEnd() + expect(virtualizerMock.scrollToEnd).not.toHaveBeenCalled() + expect(container.scrollTop).toBe(2000) + + rerender({ slots: [slot('a')] }) + result.current.scrollToEnd() + expect(virtualizerMock.scrollToEnd).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts index 19f07713ebfa..495cdd87cfe3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts @@ -283,18 +283,27 @@ export function useNativeChatTranscriptWindow({ return } finishReaderTakeover() - if (virtualizer.scrollElement) { + // With no windowed row it would resolve the end from its own rows' height, 0, though rows + // drawn after the window (a message shown as not sent) still fill the container. + if (virtualizer.scrollElement && slots.length > 0) { virtualizer.scrollToEnd({ behavior: 'auto' }) return } - // No virtualizer yet (a container without layout): the document's own bottom - // is the same offset the virtualizer would resolve for the last row. + // No virtualizer yet (a container without layout), or no windowed row: the document's own + // bottom is the same offset the virtualizer would resolve for the last row. const previous = container.scrollTop container.scrollTop = container.scrollHeight if (container.scrollTop !== previous) { programmaticScrollMarks.mark(container.scrollTop) } - }, [finishReaderTakeover, isVisible, programmaticScrollMarks, scrollRef, virtualizer]) + }, [ + finishReaderTakeover, + isVisible, + programmaticScrollMarks, + scrollRef, + slots.length, + virtualizer + ]) const restoreScrollOffset = useCallback( (offset: number) => { From 686d8a494ff287bf8fcface81fdb392681e1c5c5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:06:41 -0700 Subject: [PATCH 6/6] test(native-chat): name the relaunch test parameter for how the refusal arrives The low-evidence lint rejects "shape" as a symbol name. --- ...use-structured-agent-session-outbox-relaunch-hold.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index 17c99f11af80..1caea870a60b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -141,8 +141,8 @@ describe('a message the host refused, across a relaunch', () => { it.each(['returned', 'thrown'] as const)( 'is not sent on its own after a relaunch; its Retry sends it (refusal %s)', - async (shape) => { - if (shape === 'returned') { + async (refusalDelivery) => { + if (refusalDelivery === 'returned') { mocks.call.mockResolvedValueOnce(newerOrcaRefusal()) } else { // As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`).