From 983d240a2066a83ef9e1e82472d3767b4a0f05b7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:47:08 -0700 Subject: [PATCH 01/15] fix(native-chat): a message in doubt the host recorded no longer holds every later send --- ...tured-agent-session-codex-stop-row.test.ts | 73 +++++++ ...tiveChatStructuredSessionDelivery.test.tsx | 13 +- ...red-agent-session-delivery-notices.test.ts | 45 +++++ ...ructured-agent-session-delivery-notices.ts | 10 +- ...ssion-outbox-dispatch.owner-change.test.ts | 4 +- ...uctured-agent-session-queued-cards.test.ts | 16 +- .../structured-agent-session-queued-cards.ts | 5 +- ...d-agent-session-command-send-gate.test.tsx | 84 +++++++- ...ructured-agent-session-delivery-notices.ts | 11 +- ...-session-outbox-in-doubt-relaunch.test.tsx | 188 ++++++++++++++++++ ...-agent-session-outbox-unconfirmed-probe.ts | 22 +- ...e-structured-agent-session-outbox.test.tsx | 33 ++- .../use-structured-agent-session-outbox.ts | 4 +- .../use-structured-agent-session.ts | 17 +- .../agent-session-refusal-retry.test.ts | 2 +- ...red-agent-session-outbox-admission.test.ts | 94 +++++++++ ...ructured-agent-session-outbox-admission.ts | 30 ++- ...ed-agent-session-outbox-retry-hold.test.ts | 14 +- ...gent-session-outbox.queue-delivery.test.ts | 2 +- src/shared/structured-agent-session-outbox.ts | 6 +- 20 files changed, 592 insertions(+), 81 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx create mode 100644 src/shared/structured-agent-session-outbox-admission.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 7f828a484688..ec5ce8fba415 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -8,6 +8,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + structuredAgentSessionSendMutation +} from '../../../shared/structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox-admission' import { CodexAppServerRequestError } from '../../codex/codex-app-server-connection' import { CodexAppServerTimeoutError } from '../../codex/codex-app-server-session' import { @@ -479,3 +485,70 @@ describe('a message after a Codex Stop whose exit was unproven', () => { expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() }) }) + +// The follow-up may already be with Codex, so the host keeps it in doubt; it never holds the +// desktop's next message, which goes to a fresh Codex. +describe('a follow-up Codex never echoed, when a Stop whose interrupt failed ends the child', () => { + it('stays in doubt, and the message queued behind it goes out', async () => { + await runningTurn() + codex.routes['turn/steer'] = turns.routes['turn/steer'] + const followUp = await send('and also this') + if (!followUp.ok) { + throw new Error(JSON.stringify(followUp.refusal)) + } + const followUpId = followUp.value.clientMessageId + await vi.waitFor(() => + expect(codex.connections.at(-1)!.calls.some((call) => call.method === 'turn/steer')).toBe( + true + ) + ) + codex.routes['turn/interrupt'] = () => { + throw interruptFailure('internal error') + } + + await stop() + await host.flushStreamedEvents(SESSION) + expect(childEndedByStop()).toBe(true) + const { submissions } = await host.journalSnapshot(SESSION) + expect(submissions.find((row) => row.clientMessageId === followUpId)).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + + // The desktop's outbox as it held them: the follow-up on its way, the next message queued. + const queued = (clientMessageId: string, text: string) => + createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: SESSION, + text, + attachments: [], + queuedAt: NOW + }) + const outbox = reconcileStructuredAgentSessionOutbox( + [ + { ...queued(followUpId, 'and also this'), state: 'dispatching', lastAttemptAt: NOW }, + queued(hostTestOperationId(), 'carry on') + ], + submissions + ) + expect(outbox[0]?.state).toBe('unconfirmed') + const admission = admitStructuredAgentSessionOutboxEntry(outbox, submissions) + expect(admission).toEqual({ state: 'dispatch', entry: outbox[1] }) + if (admission.state !== 'dispatch') { + return + } + + launch.resumeThreadId = THREAD + expect( + await host.send(CALLER, structuredAgentSessionSendMutation(admission.entry, 1)) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) + await vi.waitFor(() => + expect( + codex.connections[1]!.calls.some( + (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') + ) + ).toBe(true) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 930da1f3da6b..5111261e123e 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -527,7 +527,7 @@ describe('NativeChatStructuredSession delivery', () => { ) }, 20000) - it('parks a host-confirmed unknown instead of probing it', async () => { + it('never probes a host-confirmed unknown, and sends what follows it', async () => { mocks.mode = 'outbox' mocks.call.mockRejectedValueOnce(new Error('socket closed')).mockResolvedValue({ ok: true, @@ -552,7 +552,7 @@ describe('NativeChatStructuredSession delivery', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const sent = mocks.call.mock.calls[0]?.[2] as { envelope: { clientOperationId: string } } - // The host now reports an unresolved unknown: another replay is the user's call. + // The host now reports an unresolved unknown: it never sends it again, so nothing waits on it. mocks.submissions = [ { clientMessageId: sent.envelope.clientOperationId, @@ -565,15 +565,18 @@ describe('NativeChatStructuredSession delivery', () => { resolvedAt: null } ] - // Queue a second message purely to re-render so the effect observes the - // new submissions; it must stay wedged behind the parked head. + // The second message also re-renders, so the effects observe the new submissions. await act(async () => { send?.('second', []) }) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3000)) }) - expect(mocks.call).toHaveBeenCalledOnce() + const texts = mocks.call.mock.calls.map( + (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text + ) + expect(texts).toEqual(['first', 'second']) + expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull() }, 20000) it('still probes while streaming batches rebuild the submissions array', async () => { 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 cdfa7b6fbc2c..d921c103b3fd 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 @@ -147,6 +147,51 @@ describe('the notice on each message that did not go through', () => { }) }) + // The host never sends it again, so a Retry has nothing to do, and nothing waits behind it. + it('says nothing on a message in doubt the host holds, nor on any message after it', () => { + const retry = vi.fn() + const outbox = [ + entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }), + entry('sent', { state: 'dispatching', lastAttemptAt: 3 }), + entry('next') + ] + const recorded: AgentJournalSubmission = { + clientMessageId: 'doubt', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'provider_closed_before_acknowledgement', + submittedAt: 2, + resolvedAt: 4, + recovered: true + } + expect( + structuredAgentSessionDeliveryNotices( + outbox, + 'Codex', + retry, + [recorded], + [], + NOT_FAILED_HERE + ).size + ).toBe(0) + // One the host may never have received still says so, with its Retry. + const unrecorded = structuredAgentSessionDeliveryNotices( + outbox, + 'Codex', + retry, + [], + [], + NOT_FAILED_HERE + ) + expect([...unrecorded.keys()]).toEqual([agentJournalSubmissionKey('doubt')]) + expect(unrecorded.get(agentJournalSubmissionKey('doubt'))?.text).toBe( + 'Message delivery is unconfirmed.' + ) + expect(unrecorded.get(agentJournalSubmissionKey('doubt'))?.onRetry).toBeDefined() + }) + 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])).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 5dbe834fd471..54099794d253 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 @@ -5,7 +5,8 @@ // 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. +// queue moves. A message in doubt that the journal holds says nothing: the host never sends it +// again, so it holds nothing up and a Retry has nothing to do. // // 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 message whose @@ -187,8 +188,9 @@ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, retry: (clientMessageId: string) => void, - /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps, and - * whose queued ones may be waiting out a refused start. */ + /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps, + * whose queued ones may be waiting out a refused start, and which say whether a message in doubt + * reached the host. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly StatedStartFailure[], @@ -203,7 +205,7 @@ export function structuredAgentSessionDeliveryNotices( onRetry: () => retry(clientMessageId), ...(retryWaitsForHost.has(clientMessageId) ? { retryPending: true as const } : {}) }) - const admission = admitStructuredAgentSessionOutboxEntry(outbox) + const admission = admitStructuredAgentSessionOutboxEntry(outbox, submissions) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 const rejected = new Map( 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 d74910154188..8d7596694890 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 @@ -33,8 +33,8 @@ describe('requeue after an owner change', () => { [dispatching('stopped', { outlivedStop: true })], 1 ) - expect(admitStructuredAgentSessionOutboxEntry([stopped]).state).toBe('blocked') + expect(admitStructuredAgentSessionOutboxEntry([stopped], []).state).toBe('blocked') const [plain] = requeueInterruptedStructuredAgentSessionDispatches([dispatching('plain')], 1) - expect(admitStructuredAgentSessionOutboxEntry([plain]).state).toBe('dispatch') + expect(admitStructuredAgentSessionOutboxEntry([plain], []).state).toBe('dispatch') }) }) 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 99fc9f019650..f2bbeddbad00 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 @@ -203,21 +203,21 @@ describe('queued message cards', () => { entry('a', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: 'queue-if-active' }), entry('b') ] - expect(ids(outboxOutsideQueuedCards(inFlight, [], true, QUEUEING))).toEqual([]) - expect(ids(outboxOutsideQueuedCards(inFlight, [], false, QUEUEING))).toEqual(['a', 'b']) + 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, QUEUEING))).toEqual(['a']) + 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, QUEUEING))).toEqual(['a']) + expect(ids(outboxOutsideQueuedCards(rejected, [], true, QUEUEING, []))).toEqual(['a']) const unconfirmed = [entry('a', { state: 'unconfirmed' }), entry('b')] - expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, 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, 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', () => { @@ -239,12 +239,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, 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, 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 75b6a82e4677..65d57d3a0d5c 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 @@ -112,10 +112,11 @@ export function outboxOutsideQueuedCards( outbox: readonly StructuredAgentSessionOutboxEntry[], heldIds: readonly string[], isWorking: boolean, - host: StructuredAgentSessionQueueDelivery + host: StructuredAgentSessionQueueDelivery, + submissions: readonly AgentJournalSubmission[] ): readonly StructuredAgentSessionOutboxEntry[] { const held = new Set(heldIds) - const admission = admitStructuredAgentSessionOutboxEntry(outbox) + const admission = admitStructuredAgentSessionOutboxEntry(outbox, submissions) const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 const next = outbox.filter((entry, index) => { const onItsWay = 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 1c5044265a16..5d4d4349f643 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 @@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({ outboxSend: vi.fn() })) let items: AgentJournalRenderItem[] = [] +let submissions: AgentJournalSubmission[] = [] +let outbox: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call @@ -18,7 +20,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions: [], + submissions, status: 'ready', error: null, hasOlder: false, @@ -32,14 +34,21 @@ vi.mock('./use-structured-agent-session-read', () => ({ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: () => 'operation-1', useStructuredAgentSessionOutbox: () => ({ - outbox: [], + outbox, error: null, send: mocks.outboxSend, retry: vi.fn() }) })) -import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' import { useStructuredAgentSession } from './use-structured-agent-session' function answer(text: string, scoped: boolean): AgentJournalRenderItem { @@ -56,6 +65,8 @@ function answer(text: string, scoped: boolean): AgentJournalRenderItem { beforeEach(() => { vi.clearAllMocks() mocks.outboxSend.mockReturnValue(true) + submissions = [] + outbox = [] }) /** Starts `/compact` and leaves its reply outstanding, then sends a message. */ @@ -90,3 +101,70 @@ it('queues a message typed during a command on a host that runs it as a turn', ( expect(sendDuringCommand()).toBe(true) expect(mocks.outboxSend).toHaveBeenCalledOnce() }) + +/** Runs `/compact` over a chat whose outbox holds one message in doubt. */ +async function compactBesideInDoubt(hostHoldsIt: boolean) { + items = [answer('from this host', true)] + outbox = [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'op-doubt', + sessionId: 'session-1', + text: 'stopped', + attachments: [], + queuedAt: 1 + }), + state: 'unconfirmed', + lastAttemptAt: 2 + } + ] + submissions = hostHoldsIt + ? [ + { + clientMessageId: 'op-doubt', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'provider_closed_before_acknowledgement', + submittedAt: 2, + resolvedAt: 3, + recovered: true + } + ] + : [] + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.conversationCommand' ? new Promise(() => {}) : Promise.resolve(null) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'codex', + target: { kind: 'local' }, + isVisible: true + }) + ) + let outcome: { accepted: boolean; error: string | null } | undefined + await act(async () => { + void result.current.runConversationCommand('compact').then((settled) => { + outcome = settled + }) + }) + return { + outcome, + sent: mocks.call.mock.calls.some(([, method]) => method === 'agentSession.conversationCommand') + } +} + +// The host never sends it again and it has no Retry, so it must not hold a command forever. +it('runs a command beside a message in doubt the host holds', async () => { + expect((await compactBesideInDoubt(true)).sent).toBe(true) +}) + +it('still waits on a message in doubt the host may never have received', async () => { + const { outcome, sent } = await compactBesideInDoubt(false) + expect(sent).toBe(false) + expect(outcome?.error).toBe( + 'Wait for pending work and messages to finish before using this command.' + ) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index db5b990124d2..d470b5a54b4d 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -25,11 +25,12 @@ export function useStructuredAgentSessionDeliveryNotices( (clientMessageId: string) => retryRef.current(clientMessageId), [] ) - // Only a rejected message, or one waiting out a refused start, reads the journal's rows, so a new - // batch of them re-renders no row else. + // Only a rejected message, one in doubt, or one waiting out a refused start reads the journal's + // rows, so a new batch of them re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') - const rejectionRows = + const journalRows = hasRejected || + controller.outbox.some((entry) => entry.state === 'unconfirmed') || controller.submissions.some(isRetryingStructuredAgentSessionStart) || failedStartsSentElsewhere(controller.submissions, controller.outbox).length > 0 ? controller.submissions @@ -44,7 +45,7 @@ export function useStructuredAgentSessionDeliveryNotices( controller.outbox, agentLabel, retryDelivery, - rejectionRows, + journalRows, startFailures, controller.failedHere, controller.retryWaitsForHost @@ -53,7 +54,7 @@ export function useStructuredAgentSessionDeliveryNotices( controller.outbox, agentLabel, retryDelivery, - rejectionRows, + journalRows, startFailures, controller.failedHere, controller.retryWaitsForHost diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx new file mode 100644 index 000000000000..21fb41182da7 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx @@ -0,0 +1,188 @@ +// @vitest-environment happy-dom + +// A message the journal holds in doubt (its child ended before answering it) never held anything +// up. A queue an earlier build saved stuck behind one is freed once the chat opens again, read from +// the journal alone: the held message is never sent again and says nothing. + +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 { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' + +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 { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} 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' +const LOCAL_TARGET = { kind: 'local' } as const + +type SendRequest = { envelope?: { clientOperationId?: string } } + +function sentIds(): string[] { + return mocks.call.mock.calls.map((call) => + String((call[2] as SendRequest | undefined)?.envelope?.clientOperationId) + ) +} + +function saved( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: SESSION, + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +/** What the host wrote when the child ended with the follow-up unanswered. */ +const IN_DOUBT: AgentJournalSubmission = { + clientMessageId: 'op-follow-up', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'provider_closed_before_acknowledgement', + submittedAt: 2, + resolvedAt: 3, + recovered: true +} + +function accepted(clientMessageId: string) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 4 }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: 4, + resolvedAt: 4 + } + } + } +} + +describe('a queue saved behind a message the host holds in doubt', () => { + afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + mocks.call.mockImplementation((_target, _method, params: SendRequest) => + Promise.resolve(accepted(String(params.envelope?.clientOperationId))) + ) + }) + + it.each([ + ['held as unconfirmed', 'unconfirmed'], + ['left on its way out', 'dispatching'] + ] as const)( + 'sends the message behind it when the chat opens again, the held one %s', + async (_case, state) => { + writeOutbox(SESSION, [saved('op-follow-up', { state, lastAttemptAt: 2 }), saved('op-next')]) + + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [IN_DOUBT] + }) + ) + + await waitFor(() => expect(sentIds()).toEqual(['op-next'])) + // Past the unconfirmed probe's first delay: the held message is never sent again. + await act(() => new Promise((resolve) => setTimeout(resolve, 1_200))) + expect(sentIds()).toEqual(['op-next']) + expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual([ + 'op-follow-up' + ]) + expect( + structuredAgentSessionDeliveryNotices( + result.current.outbox, + 'Codex', + result.current.retry, + [IN_DOUBT], + [], + result.current.failedHere + ).size + ).toBe(0) + } + ) + + // The probe skips the one the host holds and resends the one it may not have. + it('probes a message the host may never have received, saved behind one it holds', async () => { + writeOutbox(SESSION, [ + saved('op-follow-up', { state: 'unconfirmed', lastAttemptAt: 2 }), + saved('op-lost', { state: 'unconfirmed', lastAttemptAt: 3 }), + saved('op-next') + ]) + + renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [IN_DOUBT] + }) + ) + + await waitFor(() => expect(sentIds()).toEqual(['op-lost', 'op-next']), { timeout: 5_000 }) + }) + + it('holds the queue until the journal shows the host has it, then sends what waits', async () => { + // Retried once already, so the probe leaves it alone and only the journal can free the queue. + writeOutbox(SESSION, [ + saved('op-follow-up', { + state: 'unconfirmed', + lastAttemptAt: 2, + retryAfterUnknownSubmittedAt: -1 + }), + saved('op-next') + ]) + const { rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + ) + await act(() => new Promise((resolve) => setTimeout(resolve, 100))) + expect(sentIds()).toEqual([]) + + rerender({ submissions: [IN_DOUBT] }) + await waitFor(() => expect(sentIds()).toEqual(['op-next'])) + }) +}) 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 1d70ad86b88c..7ead2f9492ab 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryInDoubtAtHost } from '../../../../shared/structured-agent-session-outbox-admission' import { commitStructuredAgentSessionOutbox, getStructuredAgentSessionOutbox @@ -29,12 +30,15 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { // moves it out of `unconfirmed`, so one wedges the whole FIFO queue. Re-issuing // the same envelope without `retryUnknown` is idempotent: the operation ledger // replays a recorded outcome, or the host performs a genuine first delivery. - // A host-confirmed unknown stays parked until the user explicitly asks Retry - // to replay the same operation. - // The first `unconfirmed` entry is the one holding the queue, at whatever index it sits: an - // unconfirmed tail behind an admitted head would otherwise wedge until the head cleared, - // which is the wedge this probe exists to prevent. - const blocker = outbox.find((entry) => entry.state === 'unconfirmed') + // One the journal holds is never probed: the host never sends it again, and it + // holds nothing up. + // The first unconfirmed entry the host may not have is the one holding the queue, at whatever + // index it sits: an unconfirmed tail behind an admitted head would otherwise wedge until the + // head cleared, which is the wedge this probe exists to prevent. + const blocker = outbox.find( + (entry) => + entry.state === 'unconfirmed' && !structuredAgentSessionEntryInDoubtAtHost(entry, submissions) + ) // Depend on primitives: `submissions` is rebuilt on every streaming batch, so an // array-identity dep would reset the backoff forever while the agent is working. // A non-null `retryAfterUnknownSubmittedAt` means the user already retried, so @@ -47,10 +51,8 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { blocker.outlivedStop !== true ? blocker.clientMessageId : null - const probeSettled = - probeId !== null && submissions.some((submission) => submission.clientMessageId === probeId) useEffect(() => { - if (probeId === null || probeSettled || !owner.attached) { + if (probeId === null || !owner.attached) { return } const attempts = probeAttemptsRef.current.id === probeId ? probeAttemptsRef.current.attempts : 0 @@ -70,5 +72,5 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { Math.min(UNCONFIRMED_PROBE_BASE_DELAY_MS * 2 ** attempts, UNCONFIRMED_PROBE_MAX_DELAY_MS) ) return () => clearTimeout(timer) - }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, probeSettled, sessionId]) + }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, sessionId]) } 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 31cdd2753bea..e73217f4828f 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 @@ -454,23 +454,23 @@ describe('useStructuredAgentSessionOutbox', () => { expect(mocks.call).toHaveBeenCalledOnce() }) - it('drains a head the host refuses to redeliver so the queue behind it advances', async () => { - // The guard refuses a retry it cannot prove is a first delivery. That must - // not leave a Retry that does nothing in front of a wedged queue: the entry - // leaves the outbox, the user is told Orca will not send it again, and the - // message queued behind it goes out. - // The second send never settles, so the refusal's error is still on screen - // when the queue behind it advances. + it('sends past a head the host holds in doubt, and drops that head when a Retry only replays it', async () => { + // The host never sends a message it recorded again, so the one behind it goes out at once. A + // Retry on the head (one an earlier build saved) is refused as a redelivery: the entry leaves + // the outbox and the user is told Orca will not send it again. mocks.call.mockImplementation(async (_target, _method, params) => { const request = params as { envelope: { clientOperationId: string } body: { blocks: { text?: string }[] } } - if (request.body.blocks[0]?.text === 'second') { - return new Promise(() => {}) - } - return unknownResultFor(request.envelope.clientOperationId, 10) + return request.body.blocks[0]?.text === 'second' + ? pendingResultFor(request.envelope.clientOperationId, 20) + : unknownResultFor(request.envelope.clientOperationId, 10) }) + const sentTexts = () => + mocks.call.mock.calls.map( + (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text + ) const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ @@ -488,20 +488,13 @@ describe('useStructuredAgentSessionOutbox', () => { rerender({ submissions: [unknownResultFor(firstId, 10).value.submission] }) act(() => expect(result.current.send('second')).toBe(true)) - expect(result.current.outbox).toHaveLength(2) + await waitFor(() => expect(sentTexts()).toEqual(['first', 'second'])) act(() => result.current.retry(firstId)) await waitFor(() => expect(result.current.outbox.some((entry) => entry.clientMessageId === firstId)).toBe(false) ) - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 200)) - }) - - const sent = mocks.call.mock.calls.map( - (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text - ) - expect(sent).toContain('second') + expect(sentTexts()).toEqual(['first', 'second', 'first']) expect(result.current.error).toBe( 'Message delivery is unconfirmed and Orca will not send it again' ) 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 51bfc0489397..804165e5ec0c 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 @@ -213,7 +213,8 @@ export function useStructuredAgentSessionOutbox(args: { void launchDispatch.then(drainAgain, drainAgain) return } - const admission = admitStructuredAgentSessionOutboxEntry(current) + // `submissions` is a dependency: the journal recording a send in doubt frees what waits behind it. + const admission = admitStructuredAgentSessionOutboxEntry(current, submissions) if (admission.state !== 'dispatch' || fence === null || inFlightIdRef.current !== null) { return } @@ -249,6 +250,7 @@ export function useStructuredAgentSessionOutbox(args: { queueCapability, queueEnabled, sessionId, + submissions, target ]) 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 bea63e608580..5ffd59545bb0 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 @@ -17,6 +17,7 @@ import { useStructuredAgentSessionHostQueuesMessagesState, useStructuredAgentSessionHostStopsConversation } from '@/runtime/structured-agent-session-host-capability' +import { structuredAgentSessionEntryInDoubtAtHost } from '../../../../shared/structured-agent-session-outbox-admission' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { isRetryingStructuredAgentSessionStart } from '../../../../shared/structured-agent-session-start-retry' import { @@ -168,11 +169,14 @@ export function useStructuredAgentSession(args: { const isWorking = transportState.isWorking const transcriptOutbox = useMemo( () => - outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, { - capability: queueCapability, - enabled: queueFollowUps - }), - [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] + outboxOutsideQueuedCards( + outbox, + queuedMessageIds, + isWorking, + { capability: queueCapability, enabled: queueFollowUps }, + submissions + ), + [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds, submissions] ) const messages = useStructuredAgentSessionMessages( transportState.journalItems, @@ -200,7 +204,8 @@ export function useStructuredAgentSession(args: { transportState.turnId || prompts.length || transportState.backgroundTasks.isMonitoring || - outbox.length + // One in doubt the host holds has nothing left to send, and no Retry to clear it. + outbox.some((entry) => !structuredAgentSessionEntryInDoubtAtHost(entry, submissions)) ), startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items), send: (command) => diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts index 79020e5695f0..ca1b4eb4a412 100644 --- a/src/shared/agent-session-refusal-retry.test.ts +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -75,7 +75,7 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( }) expect(disposition.entries[0]).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true) - expect(admitStructuredAgentSessionOutboxEntry(disposition.entries)).toMatchObject({ + expect(admitStructuredAgentSessionOutboxEntry(disposition.entries, [])).toMatchObject({ state: 'dispatch', entry: { clientMessageId: 'message-3' } }) diff --git a/src/shared/structured-agent-session-outbox-admission.test.ts b/src/shared/structured-agent-session-outbox-admission.test.ts new file mode 100644 index 000000000000..a01cdc5490a6 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-admission.test.ts @@ -0,0 +1,94 @@ +// A message in doubt holds the queue only while the host may never have received it: once the +// journal holds it, its place is fixed and the host never sends it again. + +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 } 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 + } +} + +function unknown(clientMessageId: string, recovered: boolean): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'provider_closed_before_acknowledgement', + submittedAt: 5, + resolvedAt: 6, + ...(recovered ? { recovered: true as const } : {}) + } +} + +describe('a message in doubt', () => { + it.each([ + ['outlived its writer', true], + ['is still outstanding', false] + ] as const)( + 'holds nothing up once the journal holds it, whether its doubt %s', + (_case, recovered) => { + const submissions = [unknown('doubt', recovered)] + const outbox = reconcileStructuredAgentSessionOutbox( + [entry('doubt', { state: 'dispatching', lastAttemptAt: 2 }), entry('next')], + submissions + ) + expect(outbox[0]?.state).toBe('unconfirmed') + expect(admitStructuredAgentSessionOutboxEntry(outbox, submissions)).toEqual({ + state: 'dispatch', + entry: outbox[1] + }) + } + ) + + it('holds the queue while the host may never have received it', () => { + const outbox = [entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }), entry('next')] + expect(admitStructuredAgentSessionOutboxEntry(outbox, [])).toEqual({ + state: 'blocked', + entry: outbox[0] + }) + // Another message's row says nothing about this one. + expect(admitStructuredAgentSessionOutboxEntry(outbox, [unknown('other', true)])).toEqual({ + state: 'blocked', + entry: outbox[0] + }) + }) + + it('stops the queue on one the host may not have, behind one it holds', () => { + const outbox = [ + entry('held', { state: 'unconfirmed', lastAttemptAt: 2 }), + entry('lost', { state: 'unconfirmed', lastAttemptAt: 3 }), + entry('next') + ] + expect(admitStructuredAgentSessionOutboxEntry(outbox, [unknown('held', true)])).toEqual({ + state: 'blocked', + entry: outbox[1] + }) + }) + + // Nothing about it changed, so a streamed batch neither rewrites the saved outbox nor redraws it. + it('is kept as it is by every reconcile while it stays in doubt', () => { + const inDoubt = entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }) + const [reconciled] = reconcileStructuredAgentSessionOutbox([inDoubt], [unknown('doubt', true)]) + expect(reconciled).toBe(inDoubt) + }) +}) diff --git a/src/shared/structured-agent-session-outbox-admission.ts b/src/shared/structured-agent-session-outbox-admission.ts index ec12333fe07a..6db4cfbbb748 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 { AgentJournalSubmission } from './agent-session-journal-types' 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,18 @@ export function structuredAgentSessionEntryHeldForRetry( return entry.state === 'queued' && entry.lastFailure !== undefined } +/** A send in doubt that the journal holds: the host fixed its place and never sends it again, so + * only whether the agent got it is unknown. */ +export function structuredAgentSessionEntryInDoubtAtHost( + entry: StructuredAgentSessionOutboxEntry, + submissions: readonly AgentJournalSubmission[] +): boolean { + return ( + entry.state === 'unconfirmed' && + submissions.some((submission) => submission.clientMessageId === entry.clientMessageId) + ) +} + export type StructuredAgentSessionOutboxAdmission = | { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry } | { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry } @@ -23,15 +36,22 @@ export type StructuredAgentSessionOutboxAdmission = * per-session serialize chain before dispatching, so it keeps its place, and waiting for its echo * costs delivery of everything queued behind it. One whose start was refused before it ran is the * exception the host makes: it waits for its next try while the messages behind it go first. 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. + * `unconfirmed` entry the host may never have received is a barrier — sending past it would + * reorder around a message that may yet land. Once the journal holds it, it is not: its place is + * fixed and the host never sends it again. Read from the journal on every call, so a relaunch frees + * a queue an earlier build held. One the user was told did not go through is not either: 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[] + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] ): StructuredAgentSessionOutboxAdmission { for (const entry of entries) { - if (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) { + if ( + entry.state === 'rejected' || + structuredAgentSessionEntryHeldForRetry(entry) || + structuredAgentSessionEntryInDoubtAtHost(entry, submissions) + ) { continue } if (entry.state === 'unconfirmed') { 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 3f0c439fc9a3..418410cb7610 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -44,8 +44,8 @@ describe('a message held for its Retry', () => { 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({ + expect(admitStructuredAgentSessionOutboxEntry([held], [])).toEqual({ state: 'idle', entry: null }) + expect(admitStructuredAgentSessionOutboxEntry([held, entry('next')], [])).toMatchObject({ state: 'dispatch', entry: { clientMessageId: 'next' } }) @@ -54,7 +54,7 @@ describe('a message held for its Retry', () => { held, entry('doubt', { state: 'unconfirmed' }), entry('next') - ]) + ], []) ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'doubt' } }) }) @@ -78,7 +78,7 @@ describe('a message held for its Retry', () => { }) // 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', () => { + it('is in doubt, and holds nothing up, once the host says it cannot tell whether it landed', () => { const submission: AgentJournalSubmission = { clientMessageId: 'held', fence: 1, @@ -95,9 +95,9 @@ describe('a message held for its Retry', () => { ) expect(reconciled[0]).toMatchObject({ state: 'unconfirmed' }) expect(reconciled[0]?.lastFailure).toBeUndefined() - expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ - state: 'blocked', - entry: { clientMessageId: 'held' } + expect(admitStructuredAgentSessionOutboxEntry(reconciled, [submission])).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'next' } }) }) }) 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 958979d25c1d..ed5cc1e14404 100644 --- a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts +++ b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts @@ -113,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))).toEqual({ + expect(admitStructuredAgentSessionOutboxEntry(next.slice(2), [])).toEqual({ state: 'blocked', entry: next[2] }) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index e6c4e26f4bcb..ce030c3ab3c4 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -251,7 +251,11 @@ export function reconcileStructuredAgentSessionOutbox( entry.retryAfterUnknownSubmittedAt !== -1 && entry.retryAfterUnknownSubmittedAt !== submission.submittedAt ) { - // In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send. + // In doubt now, not failed. The host has it, so it holds nothing up (admission). + if (entry.state === 'unconfirmed' && !entry.lastFailure) { + // Kept as is, so a streamed batch neither rewrites the saved outbox nor redraws it. + return [entry] + } const { lastFailure: _superseded, ...inDoubt } = entry return [{ ...inDoubt, state: 'unconfirmed' as const }] } From 6e4a6b053c45cdfd1fc353b3b4dedd515c03e247 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:47:32 -0700 Subject: [PATCH 02/15] test(native-chat): a message in doubt the host holds leaves later queue sends on their way --- .../native-chat/structured-agent-session-queued-cards.test.ts | 3 +++ 1 file changed, 3 insertions(+) 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 f2bbeddbad00..a257fd13c568 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 @@ -218,6 +218,9 @@ describe('queued message cards', () => { 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, QUEUEING, []))).toEqual(['b']) + // One in doubt the journal holds stops nothing: what follows it is on its way to a card. + const recorded = [submission('a', 'unknown')] + expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, QUEUEING, recorded))).toEqual(['a']) }) it('hides a send only by what its request carries: a plain one is always a bubble', () => { From 460dc9776fc7350ee4c0b503c3198e041faf33b3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:50:20 -0700 Subject: [PATCH 03/15] style: format in-doubt tests --- ...ructured-agent-session-delivery-notices.test.ts | 10 ++-------- ...agent-session-outbox-in-doubt-relaunch.test.tsx | 4 +--- ...uctured-agent-session-outbox-retry-hold.test.ts | 14 ++++++++------ 3 files changed, 11 insertions(+), 17 deletions(-) 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 d921c103b3fd..124dd732d005 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,14 +167,8 @@ describe('the notice on each message that did not go through', () => { recovered: true } expect( - structuredAgentSessionDeliveryNotices( - outbox, - 'Codex', - retry, - [recorded], - [], - NOT_FAILED_HERE - ).size + structuredAgentSessionDeliveryNotices(outbox, 'Codex', retry, [recorded], [], NOT_FAILED_HERE) + .size ).toBe(0) // One the host may never have received still says so, with its Retry. const unrecorded = structuredAgentSessionDeliveryNotices( diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx index 21fb41182da7..86b433012d83 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx @@ -123,9 +123,7 @@ describe('a queue saved behind a message the host holds in doubt', () => { // Past the unconfirmed probe's first delay: the held message is never sent again. await act(() => new Promise((resolve) => setTimeout(resolve, 1_200))) expect(sentIds()).toEqual(['op-next']) - expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual([ - 'op-follow-up' - ]) + expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual(['op-follow-up']) expect( structuredAgentSessionDeliveryNotices( result.current.outbox, 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 418410cb7610..7a6b10c4bf04 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -44,17 +44,19 @@ describe('a message held for its Retry', () => { 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], [])).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') - ], []) + admitStructuredAgentSessionOutboxEntry( + [held, entry('doubt', { state: 'unconfirmed' }), entry('next')], + [] + ) ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'doubt' } }) }) From 77b692de30642903571b6a0a14de31031632ef48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:50:56 -0700 Subject: [PATCH 04/15] test(native-chat): read sent ids without a cast --- ...red-agent-session-outbox-in-doubt-relaunch.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx index 86b433012d83..48f318ae0030 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx @@ -30,10 +30,12 @@ const LOCAL_TARGET = { kind: 'local' } as const 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) => - String((call[2] as SendRequest | undefined)?.envelope?.clientOperationId) - ) + return mocks.call.mock.calls.map((call) => requestId(call[2])) } function saved( @@ -98,7 +100,7 @@ describe('a queue saved behind a message the host holds in doubt', () => { localStorage.clear() setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) mocks.call.mockImplementation((_target, _method, params: SendRequest) => - Promise.resolve(accepted(String(params.envelope?.clientOperationId))) + Promise.resolve(accepted(requestId(params))) ) }) From dec5087c940c8e7705f893e87a3f993cb8fe1eb3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:14:30 -0700 Subject: [PATCH 05/15] fix(native-chat): a message recorded in doubt leaves the outbox, and one never delivered shows from its journal row --- ...tured-agent-session-codex-stop-row.test.ts | 15 +- ...red-agent-session-delivery-notices.test.ts | 39 ----- ...ructured-agent-session-delivery-notices.ts | 34 +++-- ...ent-session-failed-start-elsewhere.test.ts | 59 ++++++++ ...ssion-outbox-dispatch.owner-change.test.ts | 4 +- ...uctured-agent-session-queued-cards.test.ts | 19 +-- .../structured-agent-session-queued-cards.ts | 5 +- ...d-agent-session-command-send-gate.test.tsx | 84 +---------- ...ed-agent-session-delivery-notices.test.tsx | 47 ++++++ ...ructured-agent-session-delivery-notices.ts | 19 ++- ...ed-agent-session-outbox-admission.test.tsx | 8 +- ...-session-outbox-in-doubt-relaunch.test.tsx | 85 ++++++++--- ...-agent-session-outbox-unconfirmed-probe.ts | 22 ++- ...e-structured-agent-session-outbox.test.tsx | 115 ++++++++------- .../use-structured-agent-session-outbox.ts | 4 +- .../use-structured-agent-session.ts | 17 +-- .../agent-session-refusal-retry.test.ts | 2 +- ...ed-agent-session-failed-start-elsewhere.ts | 46 ++++-- ...ctured-agent-session-message-projection.ts | 18 ++- ...red-agent-session-outbox-admission.test.ts | 94 ------------ ...ructured-agent-session-outbox-admission.ts | 32 +--- ...gent-session-outbox-recorded-doubt.test.ts | 137 ++++++++++++++++++ ...ed-agent-session-outbox-retry-hold.test.ts | 24 ++- ...gent-session-outbox.queue-delivery.test.ts | 2 +- src/shared/structured-agent-session-outbox.ts | 12 +- ...ructured-agent-session-send-disposition.ts | 30 ++-- 26 files changed, 538 insertions(+), 435 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx delete mode 100644 src/shared/structured-agent-session-outbox-admission.test.ts create mode 100644 src/shared/structured-agent-session-outbox-recorded-doubt.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index ec5ce8fba415..0ee7ba9ef961 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -515,7 +515,8 @@ describe('a follow-up Codex never echoed, when a Stop whose interrupt failed end recovered: true }) - // The desktop's outbox as it held them: the follow-up on its way, the next message queued. + // The desktop's outbox as it held them: the follow-up on its way, the next message queued. The + // follow-up's entry goes, as its journal row draws it. const queued = (clientMessageId: string, text: string) => createStructuredAgentSessionOutboxEntry({ clientMessageId, @@ -524,16 +525,14 @@ describe('a follow-up Codex never echoed, when a Stop whose interrupt failed end attachments: [], queuedAt: NOW }) + const next = queued(hostTestOperationId(), 'carry on') const outbox = reconcileStructuredAgentSessionOutbox( - [ - { ...queued(followUpId, 'and also this'), state: 'dispatching', lastAttemptAt: NOW }, - queued(hostTestOperationId(), 'carry on') - ], + [{ ...queued(followUpId, 'and also this'), state: 'dispatching', lastAttemptAt: NOW }, next], submissions ) - expect(outbox[0]?.state).toBe('unconfirmed') - const admission = admitStructuredAgentSessionOutboxEntry(outbox, submissions) - expect(admission).toEqual({ state: 'dispatch', entry: outbox[1] }) + expect(outbox).toEqual([next]) + const admission = admitStructuredAgentSessionOutboxEntry(outbox) + expect(admission).toEqual({ state: 'dispatch', entry: next }) if (admission.state !== 'dispatch') { return } 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 124dd732d005..cdfa7b6fbc2c 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 @@ -147,45 +147,6 @@ describe('the notice on each message that did not go through', () => { }) }) - // The host never sends it again, so a Retry has nothing to do, and nothing waits behind it. - it('says nothing on a message in doubt the host holds, nor on any message after it', () => { - const retry = vi.fn() - const outbox = [ - entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }), - entry('sent', { state: 'dispatching', lastAttemptAt: 3 }), - entry('next') - ] - const recorded: AgentJournalSubmission = { - clientMessageId: 'doubt', - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'unknown', - providerItemId: null, - reason: 'provider_closed_before_acknowledgement', - submittedAt: 2, - resolvedAt: 4, - recovered: true - } - expect( - structuredAgentSessionDeliveryNotices(outbox, 'Codex', retry, [recorded], [], NOT_FAILED_HERE) - .size - ).toBe(0) - // One the host may never have received still says so, with its Retry. - const unrecorded = structuredAgentSessionDeliveryNotices( - outbox, - 'Codex', - retry, - [], - [], - NOT_FAILED_HERE - ) - expect([...unrecorded.keys()]).toEqual([agentJournalSubmissionKey('doubt')]) - expect(unrecorded.get(agentJournalSubmissionKey('doubt'))?.text).toBe( - 'Message delivery is unconfirmed.' - ) - expect(unrecorded.get(agentJournalSubmissionKey('doubt'))?.onRetry).toBeDefined() - }) - 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])).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 54099794d253..1572d02a53c2 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 @@ -5,14 +5,14 @@ // 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 in doubt that the journal holds says nothing: the host never sends it -// again, so it holds nothing up and a Retry has nothing to do. +// 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 message whose // start was refused before it ran and that waits for its next try says why and that Orca tries // again, whoever sent it. A rejection that is a failed start's, the fact a loaded start row from an older host -// states, says only that it was not sent: the row already says why. +// states, says only that it was not sent: the row already says why. One the agent's history showed +// it never got says only that it was not sent, whoever sent it. import { readAgentSessionFailureFact, @@ -40,7 +40,10 @@ import { } from '../../../../shared/agent-session-failure-words' import { joinSentences } from '../../../../shared/sentence-joining' import { isRetryingStructuredAgentSessionStart } from '../../../../shared/structured-agent-session-start-retry' -import { failedStartsSentElsewhere } from '../../../../shared/structured-agent-session-failed-start-elsewhere' +import { + failedStartsSentElsewhere, + undeliveredSentElsewhere +} from '../../../../shared/structured-agent-session-failed-start-elsewhere' import { sayAgentSessionFailureTranslated } from './agent-session-failure-words-text' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' import { translate } from '@/i18n/i18n' @@ -140,6 +143,13 @@ function rejectionStatedByItsStartRow( ) } +function notSentText(): string { + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', + 'Message was not sent.' + ) +} + function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, @@ -157,10 +167,7 @@ function deliveryNoticeText( ) } if (!entry.lastFailure) { - return translate( - 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', - 'Message was not sent.' - ) + return notSentText() } // An earlier attempt under the id the host forgot may already be in the chat. if (structuredAgentSessionEntryIdExpired(entry)) { @@ -188,9 +195,8 @@ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, retry: (clientMessageId: string) => void, - /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps, - * whose queued ones may be waiting out a refused start, and which say whether a message in doubt - * reached the host. */ + /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps, and + * whose queued ones may be waiting out a refused start. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly StatedStartFailure[], @@ -205,7 +211,7 @@ export function structuredAgentSessionDeliveryNotices( onRetry: () => retry(clientMessageId), ...(retryWaitsForHost.has(clientMessageId) ? { retryPending: true as const } : {}) }) - const admission = admitStructuredAgentSessionOutboxEntry(outbox, submissions) + 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( @@ -259,5 +265,9 @@ export function structuredAgentSessionDeliveryNotices( ...retryControlFor(clientMessageId) }) } + // The host cannot queue it again, so there is no Retry: the person sends it anew. + for (const { clientMessageId } of undeliveredSentElsewhere(submissions, outbox)) { + notices.set(agentJournalSubmissionKey(clientMessageId), { text: notSentText() }) + } return notices } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts index 6f4ac8504bb5..ad04413a3cb3 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts @@ -230,3 +230,62 @@ describe('a message whose start failed for good, queued again by its Retry', () ]) }) }) + +// Handed over, then the agent's history showed it never got it. No outbox entry shows it — this +// client let go once the host recorded it in doubt, or another device sent it — so the journal does. +describe('a message handed over and then shown never delivered', () => { + const undelivered = (): AgentJournalSubmission => ({ + ...rejected({ kind: 'notDelivered' }), + handedOverAt: 5, + recovered: true + }) + + it('shows as unsent with no Retry, on any host', () => { + const submissions = [undelivered()] + for (const showsFailedStartsSentElsewhere of [true, false]) { + expect( + projectStructuredAgentSessionMessages([TEXT], [], submissions, { + showsFailedStartsSentElsewhere + }) + ).toEqual([expect.objectContaining({ id: KEY, unsent: true })]) + } + // The host cannot queue a message it handed over again, so the person sends it anew. + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', vi.fn(), submissions, [], new Set()).get( + KEY + ) + ).toEqual({ text: 'Message was not sent.' }) + }) + + it('is drawn once by the entry of the client that still holds it', () => { + const own = reconcileStructuredAgentSessionOutbox( + [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: ID, + sessionId: 'session-1', + text: 'Continue where you left off', + attachments: [], + queuedAt: 4 + }), + state: 'dispatching' + } + ], + [undelivered()] + ) + expect( + projectStructuredAgentSessionMessages([TEXT], own, [undelivered()]).map(({ id }) => id) + ).toEqual([KEY]) + }) + + it('stays hidden once the same words went through since', () => { + const copy: AgentJournalSubmission = { + ...undelivered(), + clientMessageId: 'copy', + dispatchState: 'accepted', + submittedAt: 9, + resolvedAt: 9 + } + expect(projectStructuredAgentSessionMessages([TEXT], [], [undelivered(), copy])).toEqual([]) + }) +}) 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 8d7596694890..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 @@ -33,8 +33,8 @@ describe('requeue after an owner change', () => { [dispatching('stopped', { outlivedStop: true })], 1 ) - expect(admitStructuredAgentSessionOutboxEntry([stopped], []).state).toBe('blocked') + expect(admitStructuredAgentSessionOutboxEntry([stopped]).state).toBe('blocked') const [plain] = requeueInterruptedStructuredAgentSessionDispatches([dispatching('plain')], 1) - expect(admitStructuredAgentSessionOutboxEntry([plain], []).state).toBe('dispatch') + expect(admitStructuredAgentSessionOutboxEntry([plain]).state).toBe('dispatch') }) }) 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 a257fd13c568..99fc9f019650 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 @@ -203,24 +203,21 @@ describe('queued message cards', () => { entry('a', { state: 'dispatching', lastAttemptAt: 2, sentDelivery: 'queue-if-active' }), entry('b') ] - expect(ids(outboxOutsideQueuedCards(inFlight, [], true, QUEUEING, []))).toEqual([]) - expect(ids(outboxOutsideQueuedCards(inFlight, [], false, QUEUEING, []))).toEqual(['a', 'b']) + 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, QUEUEING, []))).toEqual(['a']) + 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, QUEUEING, []))).toEqual(['a']) + expect(ids(outboxOutsideQueuedCards(rejected, [], true, QUEUEING))).toEqual(['a']) const unconfirmed = [entry('a', { state: 'unconfirmed' }), entry('b')] - expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, 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, QUEUEING, []))).toEqual(['b']) - // One in doubt the journal holds stops nothing: what follows it is on its way to a card. - const recorded = [submission('a', 'unknown')] - expect(ids(outboxOutsideQueuedCards(unconfirmed, [], true, QUEUEING, recorded))).toEqual(['a']) + 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', () => { @@ -242,12 +239,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, 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, 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 65d57d3a0d5c..75b6a82e4677 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 @@ -112,11 +112,10 @@ export function outboxOutsideQueuedCards( outbox: readonly StructuredAgentSessionOutboxEntry[], heldIds: readonly string[], isWorking: boolean, - host: StructuredAgentSessionQueueDelivery, - submissions: readonly AgentJournalSubmission[] + host: StructuredAgentSessionQueueDelivery ): readonly StructuredAgentSessionOutboxEntry[] { const held = new Set(heldIds) - const admission = admitStructuredAgentSessionOutboxEntry(outbox, submissions) + const admission = admitStructuredAgentSessionOutboxEntry(outbox) const stalledFrom = admission.state === 'blocked' ? outbox.indexOf(admission.entry) : -1 const next = outbox.filter((entry, index) => { const onItsWay = 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 5d4d4349f643..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 @@ -8,8 +8,6 @@ const mocks = vi.hoisted(() => ({ outboxSend: vi.fn() })) let items: AgentJournalRenderItem[] = [] -let submissions: AgentJournalSubmission[] = [] -let outbox: StructuredAgentSessionOutboxEntry[] = [] vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call @@ -20,7 +18,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items, - submissions, + submissions: [], status: 'ready', error: null, hasOlder: false, @@ -34,21 +32,14 @@ vi.mock('./use-structured-agent-session-read', () => ({ vi.mock('./use-structured-agent-session-outbox', () => ({ structuredSessionOperationId: () => 'operation-1', useStructuredAgentSessionOutbox: () => ({ - outbox, + outbox: [], error: null, send: mocks.outboxSend, retry: vi.fn() }) })) -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import { - createStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import { useStructuredAgentSession } from './use-structured-agent-session' function answer(text: string, scoped: boolean): AgentJournalRenderItem { @@ -65,8 +56,6 @@ function answer(text: string, scoped: boolean): AgentJournalRenderItem { beforeEach(() => { vi.clearAllMocks() mocks.outboxSend.mockReturnValue(true) - submissions = [] - outbox = [] }) /** Starts `/compact` and leaves its reply outstanding, then sends a message. */ @@ -101,70 +90,3 @@ it('queues a message typed during a command on a host that runs it as a turn', ( expect(sendDuringCommand()).toBe(true) expect(mocks.outboxSend).toHaveBeenCalledOnce() }) - -/** Runs `/compact` over a chat whose outbox holds one message in doubt. */ -async function compactBesideInDoubt(hostHoldsIt: boolean) { - items = [answer('from this host', true)] - outbox = [ - { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'op-doubt', - sessionId: 'session-1', - text: 'stopped', - attachments: [], - queuedAt: 1 - }), - state: 'unconfirmed', - lastAttemptAt: 2 - } - ] - submissions = hostHoldsIt - ? [ - { - clientMessageId: 'op-doubt', - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState: 'unknown', - providerItemId: null, - reason: 'provider_closed_before_acknowledgement', - submittedAt: 2, - resolvedAt: 3, - recovered: true - } - ] - : [] - mocks.call.mockImplementation((_target: unknown, method: string) => - method === 'agentSession.conversationCommand' ? new Promise(() => {}) : Promise.resolve(null) - ) - const { result } = renderHook(() => - useStructuredAgentSession({ - sessionId: 'session-1', - agent: 'codex', - target: { kind: 'local' }, - isVisible: true - }) - ) - let outcome: { accepted: boolean; error: string | null } | undefined - await act(async () => { - void result.current.runConversationCommand('compact').then((settled) => { - outcome = settled - }) - }) - return { - outcome, - sent: mocks.call.mock.calls.some(([, method]) => method === 'agentSession.conversationCommand') - } -} - -// The host never sends it again and it has no Retry, so it must not hold a command forever. -it('runs a command beside a message in doubt the host holds', async () => { - expect((await compactBesideInDoubt(true)).sent).toBe(true) -}) - -it('still waits on a message in doubt the host may never have received', async () => { - const { outcome, sent } = await compactBesideInDoubt(false) - expect(sent).toBe(false) - expect(outcome?.error).toBe( - 'Wait for pending work and messages to finish before using this command.' - ) -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx new file mode 100644 index 000000000000..e94444e4a350 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment happy-dom + +// The chat's notices read the journal's rows only when a row of them says something: a message the +// agent's history showed it never got is one, drawn from its row on a client with no entry for it. + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' + +afterEach(cleanup) + +it('says a message the agent never got was not sent, with no Retry, on a client with no entry', () => { + const undelivered: AgentJournalSubmission = { + clientMessageId: 'op-undelivered', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + submittedAt: 4, + resolvedAt: 7, + handoverRecorded: true, + handedOverAt: 5, + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + } + const { result } = renderHook(() => + useStructuredAgentSessionDeliveryNotices( + { + retry: vi.fn(), + retryWaitsForHost: new Set(), + outbox: [], + submissions: [undelivered], + journalItems: [], + failedHere: new Set() + }, + 'Claude' + ) + ) + + expect(result.current.get(agentJournalSubmissionKey('op-undelivered'))).toEqual({ + text: 'Message was not sent.' + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts index d470b5a54b4d..7458662bd0c5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { failedStartsSentElsewhere } from '../../../../shared/structured-agent-session-failed-start-elsewhere' +import { + failedStartsSentElsewhere, + undeliveredSentElsewhere +} from '../../../../shared/structured-agent-session-failed-start-elsewhere' import { isRetryingStructuredAgentSessionStart } from '../../../../shared/structured-agent-session-start-retry' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import type { useStructuredAgentSession } from './use-structured-agent-session' @@ -25,14 +28,14 @@ export function useStructuredAgentSessionDeliveryNotices( (clientMessageId: string) => retryRef.current(clientMessageId), [] ) - // Only a rejected message, one in doubt, or one waiting out a refused start reads the journal's - // rows, so a new batch of them re-renders no row else. + // Only a rejected message, one waiting out a refused start, or one shown from the journal as unsent + // reads the journal's rows, so a new batch of them re-renders no row else. const hasRejected = controller.outbox.some((entry) => entry.state === 'rejected') - const journalRows = + const rejectionRows = hasRejected || - controller.outbox.some((entry) => entry.state === 'unconfirmed') || controller.submissions.some(isRetryingStructuredAgentSessionStart) || - failedStartsSentElsewhere(controller.submissions, controller.outbox).length > 0 + failedStartsSentElsewhere(controller.submissions, controller.outbox).length > 0 || + undeliveredSentElsewhere(controller.submissions, controller.outbox).length > 0 ? controller.submissions : NO_SUBMISSIONS const startFailures = useStructuredAgentSessionStartFailureFacts( @@ -45,7 +48,7 @@ export function useStructuredAgentSessionDeliveryNotices( controller.outbox, agentLabel, retryDelivery, - journalRows, + rejectionRows, startFailures, controller.failedHere, controller.retryWaitsForHost @@ -54,7 +57,7 @@ export function useStructuredAgentSessionDeliveryNotices( controller.outbox, agentLabel, retryDelivery, - journalRows, + rejectionRows, startFailures, controller.failedHere, controller.retryWaitsForHost 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 083a128ca0e8..c7578e9a2b19 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 @@ -298,7 +298,7 @@ describe('structured agent session outbox admission', () => { expect(heldIds(result.current.outbox)).toEqual([heldId]) }) - it('keeps a refused message held when an unconfirmed message ahead of it is retried', async () => { + it('keeps a refused message held when the message in doubt ahead of it leaves', async () => { const none: readonly AgentJournalSubmission[] = [] mocks.call.mockImplementation((_target, _method, params) => { const id = requestId(params) @@ -329,12 +329,10 @@ describe('structured agent session outbox admission', () => { dispatchState: 'unknown' } rerender({ submissions: [unknown] }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) - - act(() => result.current.retry(firstId)) + // The host recorded it and never sends it again, so its entry goes; the refused one stays held. await waitFor(() => expect(result.current.outbox).toHaveLength(1)) await settleTimers(50) - expect(sentTexts()).toEqual(['first', 'held', 'first']) + expect(sentTexts()).toEqual(['first', 'held']) expect(heldIds(result.current.outbox)).toEqual([heldId]) }) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx index 48f318ae0030..333412e3eabc 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment happy-dom -// A message the journal holds in doubt (its child ended before answering it) never held anything -// up. A queue an earlier build saved stuck behind one is freed once the chat opens again, read from -// the journal alone: the held message is never sent again and says nothing. +// A message the journal records in doubt (its child ended before answering it) leaves the outbox, +// so nothing waits on it, now or after a reopen whose loaded rows no longer reach it. A queue an +// earlier build saved stuck behind one is freed once the chat opens again, and the held message is +// never sent again. import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,7 +24,6 @@ import { type StructuredAgentSessionOutboxEntry } 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' const LOCAL_TARGET = { kind: 'local' } as const @@ -99,8 +99,19 @@ describe('a queue saved behind a message the host holds in doubt', () => { vi.clearAllMocks() localStorage.clear() setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY]) + // The host answers a replay of the recorded follow-up with its doubt, and takes anything else. mocks.call.mockImplementation((_target, _method, params: SendRequest) => - Promise.resolve(accepted(requestId(params))) + Promise.resolve( + requestId(params) === IN_DOUBT.clientMessageId + ? { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 4 }, + value: { clientMessageId: IN_DOUBT.clientMessageId, submission: IN_DOUBT } + } + : accepted(requestId(params)) + ) ) }) @@ -125,21 +136,61 @@ describe('a queue saved behind a message the host holds in doubt', () => { // Past the unconfirmed probe's first delay: the held message is never sent again. await act(() => new Promise((resolve) => setTimeout(resolve, 1_200))) expect(sentIds()).toEqual(['op-next']) - expect(result.current.outbox.map((entry) => entry.clientMessageId)).toEqual(['op-follow-up']) - expect( - structuredAgentSessionDeliveryNotices( - result.current.outbox, - 'Codex', - result.current.retry, - [IN_DOUBT], - [], - result.current.failedHere - ).size - ).toBe(0) + expect(result.current.outbox).toEqual([]) } ) - // The probe skips the one the host holds and resends the one it may not have. + // Its rows can age out of what a reopen loads; the entry left with the first reconcile. + it('never holds a send after a reopen whose loaded rows no longer reach it', async () => { + writeOutbox(SESSION, [saved('op-follow-up', { state: 'dispatching', lastAttemptAt: 2 })]) + const before = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [IN_DOUBT] + }) + ) + await waitFor(() => expect(before.result.current.outbox).toEqual([])) + before.unmount() + + const after = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + act(() => expect(after.result.current.send('next')).toBe(true)) + await waitFor(() => expect(after.result.current.outbox).toEqual([])) + expect(sentIds()).toHaveLength(1) + expect(sentIds()).not.toContain(IN_DOUBT.clientMessageId) + }) + + // A client that never loaded the row: the probe's replay is the host's answer that it has it. + it('frees a queue whose message in doubt was recorded out of sight, by the replay', async () => { + writeOutbox(SESSION, [ + saved('op-follow-up', { state: 'unconfirmed', lastAttemptAt: 2 }), + saved('op-next') + ]) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + await waitFor(() => expect(sentIds()).toEqual(['op-follow-up', 'op-next']), { + timeout: 5_000 + }) + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(result.current.error).toBeNull() + }) + + // The one the host holds leaves; the probe resends the one it may not have. it('probes a message the host may never have received, saved behind one it holds', async () => { writeOutbox(SESSION, [ saved('op-follow-up', { state: 'unconfirmed', lastAttemptAt: 2 }), 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 7ead2f9492ab..1d70ad86b88c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-unconfirmed-probe.ts @@ -1,7 +1,6 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionEntryInDoubtAtHost } from '../../../../shared/structured-agent-session-outbox-admission' import { commitStructuredAgentSessionOutbox, getStructuredAgentSessionOutbox @@ -30,15 +29,12 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { // moves it out of `unconfirmed`, so one wedges the whole FIFO queue. Re-issuing // the same envelope without `retryUnknown` is idempotent: the operation ledger // replays a recorded outcome, or the host performs a genuine first delivery. - // One the journal holds is never probed: the host never sends it again, and it - // holds nothing up. - // The first unconfirmed entry the host may not have is the one holding the queue, at whatever - // index it sits: an unconfirmed tail behind an admitted head would otherwise wedge until the - // head cleared, which is the wedge this probe exists to prevent. - const blocker = outbox.find( - (entry) => - entry.state === 'unconfirmed' && !structuredAgentSessionEntryInDoubtAtHost(entry, submissions) - ) + // A host-confirmed unknown stays parked until the user explicitly asks Retry + // to replay the same operation. + // The first `unconfirmed` entry is the one holding the queue, at whatever index it sits: an + // unconfirmed tail behind an admitted head would otherwise wedge until the head cleared, + // which is the wedge this probe exists to prevent. + const blocker = outbox.find((entry) => entry.state === 'unconfirmed') // Depend on primitives: `submissions` is rebuilt on every streaming batch, so an // array-identity dep would reset the backoff forever while the agent is working. // A non-null `retryAfterUnknownSubmittedAt` means the user already retried, so @@ -51,8 +47,10 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { blocker.outlivedStop !== true ? blocker.clientMessageId : null + const probeSettled = + probeId !== null && submissions.some((submission) => submission.clientMessageId === probeId) useEffect(() => { - if (probeId === null || !owner.attached) { + if (probeId === null || probeSettled || !owner.attached) { return } const attempts = probeAttemptsRef.current.id === probeId ? probeAttemptsRef.current.attempts : 0 @@ -72,5 +70,5 @@ export function useStructuredAgentSessionOutboxUnconfirmedProbe(args: { Math.min(UNCONFIRMED_PROBE_BASE_DELAY_MS * 2 ** attempts, UNCONFIRMED_PROBE_MAX_DELAY_MS) ) return () => clearTimeout(timer) - }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, sessionId]) + }, [owner.attached, owner.ownerChange, owner.targetKey, probeId, probeSettled, sessionId]) } 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 629b2be74d48..a7cf45e8c5e4 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 @@ -456,10 +456,8 @@ describe('useStructuredAgentSessionOutbox', () => { expect(mocks.call).toHaveBeenCalledOnce() }) - it('sends past a head the host holds in doubt, and drops that head when a Retry only replays it', async () => { - // The host never sends a message it recorded again, so the one behind it goes out at once. A - // Retry on the head (one an earlier build saved) is refused as a redelivery: the entry leaves - // the outbox and the user is told to check the chat before sending it again. + it('drops a head the host answers in doubt, and sends what follows it at once', async () => { + // The host recorded it and never sends it again, so nothing waits on it and nothing is said. mocks.call.mockImplementation(async (_target, _method, params) => { const request = params as { envelope: { clientOperationId: string } @@ -473,30 +471,58 @@ describe('useStructuredAgentSessionOutbox', () => { mocks.call.mock.calls.map( (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text ) - const { result, rerender } = renderHook( - ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions - }), - { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) ) act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) - const firstId = result.current.outbox[0]!.clientMessageId - rerender({ submissions: [unknownResultFor(firstId, 10).value.submission] }) - + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) act(() => expect(result.current.send('second')).toBe(true)) await waitFor(() => expect(sentTexts()).toEqual(['first', 'second'])) + expect(result.current.error).toBeNull() + }) - act(() => result.current.retry(firstId)) - await waitFor(() => - expect(result.current.outbox.some((entry) => entry.clientMessageId === firstId)).toBe(false) + it('drops a Retry an earlier build saved on a recorded doubt once the host only replays it', async () => { + // A Retry asked of this very submission stays for its answer: the host refuses to send it again, + // and the user is told to check the chat before sending it again. + const recorded = unknownResultFor('op-retried', 10).value.submission + localStorage.setItem( + 'orca:desktopStructuredAgentSessionOutbox:v1:session-1', + JSON.stringify([ + { + clientMessageId: 'op-retried', + sessionId: 'session-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'retried' }] }, + previewUris: [], + state: 'queued', + queuedAt: 1, + lastAttemptAt: 2, + retryAfterUnknownSubmittedAt: recorded.submittedAt + } + ]) ) - expect(sentTexts()).toEqual(['first', 'second', 'first']) + mocks.call.mockImplementation(async (_target, _method, params) => + unknownResultFor( + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId, + 10 + ) + ) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [recorded] + }) + ) + + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(mocks.call).toHaveBeenCalledOnce() expect(result.current.error).toBe( "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." ) @@ -597,27 +623,18 @@ describe('useStructuredAgentSessionOutbox', () => { }) }) - it('retries an unknown head and advances a queued tail', async () => { + it('drops a head in transport doubt once the journal records it, and sends the tail', async () => { // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test vi.mocked(globalThis.crypto.randomUUID) .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') mocks.call - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return unknownResultFor(clientMessageId, 10) - }) + .mockRejectedValueOnce(new Error('socket closed')) .mockImplementationOnce(async (_target, _method, params) => { const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope .clientOperationId return acceptedResultFor(clientMessageId, 11) }) - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return acceptedResultFor(clientMessageId, 12) - }) const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ @@ -634,6 +651,12 @@ describe('useStructuredAgentSessionOutbox', () => { }) await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) const firstId = result.current.outbox[0]!.clientMessageId + act(() => { + expect(result.current.send('second')).toBe(true) + }) + // The host may never have received it, so the tail waits. + expect(result.current.outbox).toHaveLength(2) + expect(mocks.call).toHaveBeenCalledOnce() rerender({ submissions: [ { @@ -648,30 +671,24 @@ describe('useStructuredAgentSessionOutbox', () => { } ] }) - act(() => { - expect(result.current.send('second')).toBe(true) - }) - expect(result.current.outbox).toHaveLength(2) - act(() => result.current.retry(firstId)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + // The host recorded it and never sends it again: it goes, and the tail goes out unasked. + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - const retryParams = mocks.call.mock.calls[1]?.[2] as { retryUnknown?: true } | undefined - expect(retryParams?.retryUnknown).toBeUndefined() + expect( + (mocks.call.mock.calls[1]?.[2] as { body: { blocks: { text?: string }[] } }).body.blocks[0] + ?.text + ).toBe('second') }) - it('rotates a history-rejected unknown head so the queued tail can advance', async () => { + it('rotates a history-rejected head in doubt so the queued tail can advance', async () => { // oxlint-disable-next-line no-restricted-properties -- stubbing the global the generator reads, to pin ids in this test vi.mocked(globalThis.crypto.randomUUID) .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') .mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc') mocks.call - .mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return unknownResultFor(clientMessageId, 10) - }) + .mockRejectedValueOnce(new Error('socket closed')) .mockImplementationOnce(async (_target, _method, params) => { const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope .clientOperationId @@ -792,11 +809,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) it('loads the new session outbox when a pane switches sessions', async () => { - mocks.call.mockImplementationOnce(async (_target, _method, params) => { - const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId - return unknownResultFor(clientMessageId, 10) - }) + mocks.call.mockRejectedValueOnce(new Error('socket closed')) const { result, rerender } = renderHook( ({ sessionId }: { sessionId: string }) => 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 87235819bc58..28bf17b8aa78 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 @@ -215,8 +215,7 @@ export function useStructuredAgentSessionOutbox(args: { void launchDispatch.then(drainAgain, drainAgain) return } - // `submissions` is a dependency: the journal recording a send in doubt frees what waits behind it. - const admission = admitStructuredAgentSessionOutboxEntry(current, submissions) + const admission = admitStructuredAgentSessionOutboxEntry(current) if (admission.state !== 'dispatch' || fence === null || inFlightIdRef.current !== null) { return } @@ -252,7 +251,6 @@ export function useStructuredAgentSessionOutbox(args: { queueCapability, queueEnabled, sessionId, - submissions, target ]) 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 5ffd59545bb0..bea63e608580 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 @@ -17,7 +17,6 @@ import { useStructuredAgentSessionHostQueuesMessagesState, useStructuredAgentSessionHostStopsConversation } from '@/runtime/structured-agent-session-host-capability' -import { structuredAgentSessionEntryInDoubtAtHost } from '../../../../shared/structured-agent-session-outbox-admission' import { hasUnsentStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-stop-withdrawal' import { isRetryingStructuredAgentSessionStart } from '../../../../shared/structured-agent-session-start-retry' import { @@ -169,14 +168,11 @@ export function useStructuredAgentSession(args: { const isWorking = transportState.isWorking const transcriptOutbox = useMemo( () => - outboxOutsideQueuedCards( - outbox, - queuedMessageIds, - isWorking, - { capability: queueCapability, enabled: queueFollowUps }, - submissions - ), - [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds, submissions] + outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, { + capability: queueCapability, + enabled: queueFollowUps + }), + [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] ) const messages = useStructuredAgentSessionMessages( transportState.journalItems, @@ -204,8 +200,7 @@ export function useStructuredAgentSession(args: { transportState.turnId || prompts.length || transportState.backgroundTasks.isMonitoring || - // One in doubt the host holds has nothing left to send, and no Retry to clear it. - outbox.some((entry) => !structuredAgentSessionEntryInDoubtAtHost(entry, submissions)) + outbox.length ), startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items), send: (command) => diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts index ca1b4eb4a412..79020e5695f0 100644 --- a/src/shared/agent-session-refusal-retry.test.ts +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -75,7 +75,7 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( }) expect(disposition.entries[0]).toMatchObject({ clientMessageId: 'message-2', state: 'queued' }) expect(structuredAgentSessionEntryHeldForRetry(disposition.entries[0]!)).toBe(true) - expect(admitStructuredAgentSessionOutboxEntry(disposition.entries, [])).toMatchObject({ + expect(admitStructuredAgentSessionOutboxEntry(disposition.entries)).toMatchObject({ state: 'dispatch', entry: { clientMessageId: 'message-3' } }) diff --git a/src/shared/structured-agent-session-failed-start-elsewhere.ts b/src/shared/structured-agent-session-failed-start-elsewhere.ts index e08978eba940..e1f3018f66fc 100644 --- a/src/shared/structured-agent-session-failed-start-elsewhere.ts +++ b/src/shared/structured-agent-session-failed-start-elsewhere.ts @@ -1,15 +1,43 @@ -// A message this client did not send — one the host sent itself, such as a restart's continuation, -// or one another device sent — whose start failed for good. No outbox entry here shows it, so the -// chat shows it from the journal, on a host that can queue it again: an unsent message that says -// why, whose Retry queues that same message again. +// A message no outbox entry here shows — one the host sent itself, such as a restart's +// continuation, one another device sent, or one this client let go once the host recorded it — +// that never reached its agent. The chat shows it from the journal as an unsent message. +// One whose start failed for good, on a host that can queue it again, says why, and its Retry +// queues that same message again. One the agent's own history later showed it never got says +// only that it was not sent, with no Retry: the host cannot queue it again. import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' -import { failedBeforeHandover } from './structured-agent-session-dispatch-rejection' +import { + classifyDispatchRejection, + failedBeforeHandover +} from './structured-agent-session-dispatch-rejection' + +type SentHere = readonly Pick[] export function failedStartsSentElsewhere( submissions: readonly AgentJournalSubmission[], - outbox: readonly Pick[] + outbox: SentHere +): AgentJournalSubmission[] { + return unsentElsewhere(submissions, outbox, failedBeforeHandover) +} + +export function undeliveredSentElsewhere( + submissions: readonly AgentJournalSubmission[], + outbox: SentHere +): AgentJournalSubmission[] { + return unsentElsewhere( + submissions, + outbox, + (submission) => + submission.dispatchState === 'rejected' && + classifyDispatchRejection(submission).kind === 'notDelivered' + ) +} + +function unsentElsewhere( + submissions: readonly AgentJournalSubmission[], + outbox: SentHere, + unsent: (submission: AgentJournalSubmission) => boolean ): AgentJournalSubmission[] { const sentHere = new Set(outbox.map((entry) => entry.clientMessageId)) return submissions.filter( @@ -17,13 +45,13 @@ export function failedStartsSentElsewhere( !sentHere.has(submission.clientMessageId) && // A queued card's message: the card shows it, and its own Retry is the card's. submission.queuedMessageId === undefined && - failedBeforeHandover(submission) && + unsent(submission) && !sentAgainSince(submission, submissions) ) } -/** The same words went through since, as a message of their own: an older host's Retry sent a new - * copy under a new id. A copy's body is the original's, so its body-only fingerprint matches. */ +/** The same words went through since, as a message of their own: a Retry that sent a new copy + * under a new id. A copy's body is the original's, so its body-only fingerprint matches. */ function sentAgainSince( original: AgentJournalSubmission, submissions: readonly AgentJournalSubmission[] diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 9854d0e282cb..67386bba3ca3 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -8,7 +8,10 @@ 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 { failedStartsSentElsewhere } from './structured-agent-session-failed-start-elsewhere' +import { + failedStartsSentElsewhere, + undeliveredSentElsewhere +} from './structured-agent-session-failed-start-elsewhere' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' export function projectStructuredAgentSessionMessages( @@ -31,14 +34,13 @@ export function projectStructuredAgentSessionMessages( .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) - // Sent from elsewhere and refused for good by a failed start: shown as unsent, as this client's - // own would be, in the conversation where the journal recorded it. + // Sent from elsewhere and refused for good by a failed start, or shown by the agent's history never + // to have reached it: shown as unsent, as this client's own would be, where the journal put it. const unsentElsewhere = new Set( - showsFailedStartsSentElsewhere - ? failedStartsSentElsewhere(submissions, outbox).map((submission) => - agentJournalSubmissionKey(submission.clientMessageId) - ) - : [] + [ + ...(showsFailedStartsSentElsewhere ? failedStartsSentElsewhere(submissions, outbox) : []), + ...undeliveredSentElsewhere(submissions, outbox) + ].map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const visibleItems: AgentJournalRenderItem[] = [] const refused = new Map() diff --git a/src/shared/structured-agent-session-outbox-admission.test.ts b/src/shared/structured-agent-session-outbox-admission.test.ts deleted file mode 100644 index a01cdc5490a6..000000000000 --- a/src/shared/structured-agent-session-outbox-admission.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// A message in doubt holds the queue only while the host may never have received it: once the -// journal holds it, its place is fixed and the host never sends it again. - -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 } 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 - } -} - -function unknown(clientMessageId: string, recovered: boolean): AgentJournalSubmission { - return { - clientMessageId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'unknown', - providerItemId: null, - reason: 'provider_closed_before_acknowledgement', - submittedAt: 5, - resolvedAt: 6, - ...(recovered ? { recovered: true as const } : {}) - } -} - -describe('a message in doubt', () => { - it.each([ - ['outlived its writer', true], - ['is still outstanding', false] - ] as const)( - 'holds nothing up once the journal holds it, whether its doubt %s', - (_case, recovered) => { - const submissions = [unknown('doubt', recovered)] - const outbox = reconcileStructuredAgentSessionOutbox( - [entry('doubt', { state: 'dispatching', lastAttemptAt: 2 }), entry('next')], - submissions - ) - expect(outbox[0]?.state).toBe('unconfirmed') - expect(admitStructuredAgentSessionOutboxEntry(outbox, submissions)).toEqual({ - state: 'dispatch', - entry: outbox[1] - }) - } - ) - - it('holds the queue while the host may never have received it', () => { - const outbox = [entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }), entry('next')] - expect(admitStructuredAgentSessionOutboxEntry(outbox, [])).toEqual({ - state: 'blocked', - entry: outbox[0] - }) - // Another message's row says nothing about this one. - expect(admitStructuredAgentSessionOutboxEntry(outbox, [unknown('other', true)])).toEqual({ - state: 'blocked', - entry: outbox[0] - }) - }) - - it('stops the queue on one the host may not have, behind one it holds', () => { - const outbox = [ - entry('held', { state: 'unconfirmed', lastAttemptAt: 2 }), - entry('lost', { state: 'unconfirmed', lastAttemptAt: 3 }), - entry('next') - ] - expect(admitStructuredAgentSessionOutboxEntry(outbox, [unknown('held', true)])).toEqual({ - state: 'blocked', - entry: outbox[1] - }) - }) - - // Nothing about it changed, so a streamed batch neither rewrites the saved outbox nor redraws it. - it('is kept as it is by every reconcile while it stays in doubt', () => { - const inDoubt = entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }) - const [reconciled] = reconcileStructuredAgentSessionOutbox([inDoubt], [unknown('doubt', true)]) - expect(reconciled).toBe(inDoubt) - }) -}) diff --git a/src/shared/structured-agent-session-outbox-admission.ts b/src/shared/structured-agent-session-outbox-admission.ts index 6db4cfbbb748..edc3945c8de9 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 { AgentJournalSubmission } from './agent-session-journal-types' 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,18 +10,6 @@ export function structuredAgentSessionEntryHeldForRetry( return entry.state === 'queued' && entry.lastFailure !== undefined } -/** A send in doubt that the journal holds: the host fixed its place and never sends it again, so - * only whether the agent got it is unknown. */ -export function structuredAgentSessionEntryInDoubtAtHost( - entry: StructuredAgentSessionOutboxEntry, - submissions: readonly AgentJournalSubmission[] -): boolean { - return ( - entry.state === 'unconfirmed' && - submissions.some((submission) => submission.clientMessageId === entry.clientMessageId) - ) -} - export type StructuredAgentSessionOutboxAdmission = | { state: 'dispatch'; entry: StructuredAgentSessionOutboxEntry } | { state: 'blocked'; entry: StructuredAgentSessionOutboxEntry } @@ -36,22 +23,17 @@ export type StructuredAgentSessionOutboxAdmission = * per-session serialize chain before dispatching, so it keeps its place, and waiting for its echo * costs delivery of everything queued behind it. One whose start was refused before it ran is the * exception the host makes: it waits for its next try while the messages behind it go first. An - * `unconfirmed` entry the host may never have received is a barrier — sending past it would - * reorder around a message that may yet land. Once the journal holds it, it is not: its place is - * fixed and the host never sends it again. Read from the journal on every call, so a relaunch frees - * a queue an earlier build held. One the user was told did not go through is not either: it lands - * only by its own Retry, so what the user sends after it goes out as they send it. + * `unconfirmed` entry is one the host may never have received, and a barrier — sending past it + * would reorder around a message that may yet land. Once the journal records it in doubt it leaves + * the outbox (the reconcile): its place is fixed and the host never sends it again. One the user + * was told did not go through is not a barrier either: 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[], - submissions: readonly AgentJournalSubmission[] + entries: readonly StructuredAgentSessionOutboxEntry[] ): StructuredAgentSessionOutboxAdmission { for (const entry of entries) { - if ( - entry.state === 'rejected' || - structuredAgentSessionEntryHeldForRetry(entry) || - structuredAgentSessionEntryInDoubtAtHost(entry, submissions) - ) { + if (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) { continue } if (entry.state === 'unconfirmed') { diff --git a/src/shared/structured-agent-session-outbox-recorded-doubt.test.ts b/src/shared/structured-agent-session-outbox-recorded-doubt.test.ts new file mode 100644 index 000000000000..918fb39264db --- /dev/null +++ b/src/shared/structured-agent-session-outbox-recorded-doubt.test.ts @@ -0,0 +1,137 @@ +// A message whose delivery is in doubt holds the queue only while the host may never have received +// it. Once the host records it in doubt, its place is fixed and the host never sends it again, so +// its entry leaves the outbox and what follows it goes out. + +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 } from './structured-agent-session-outbox-admission' +import { disposeStructuredAgentSessionSendResult } from './structured-agent-session-send-disposition' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function unknown( + clientMessageId: string, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'provider_closed_before_acknowledgement', + submittedAt: 5, + resolvedAt: 6, + recovered: true, + ...patch + } +} + +function answered( + submission: AgentJournalSubmission, + entries: StructuredAgentSessionOutboxEntry[] +) { + return disposeStructuredAgentSessionSendResult({ + entries, + entry: entries[0]!, + result: { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 7 }, + value: { clientMessageId: submission.clientMessageId, submission } + }, + createOperationId: () => 'unused' + }) +} + +describe('a message the host records in doubt', () => { + it.each<[string, Partial, Partial]>([ + ['on its way, the doubt outliving its writer', { state: 'dispatching' }, {}], + [ + 'already unconfirmed, the doubt still outstanding', + { state: 'unconfirmed' }, + { recovered: undefined } + ], + [ + 'retried before the record was seen', + { state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }, + {} + ] + ])('leaves the outbox when the journal records it, %s', (_case, entryPatch, recorded) => { + const outbox = reconcileStructuredAgentSessionOutbox( + [entry('doubt', { lastAttemptAt: 2, ...entryPatch }), entry('next')], + [unknown('doubt', recorded)] + ) + expect(outbox.map((candidate) => candidate.clientMessageId)).toEqual(['next']) + expect(admitStructuredAgentSessionOutboxEntry(outbox)).toEqual({ + state: 'dispatch', + entry: outbox[0] + }) + }) + + it('leaves the outbox when the host answers a send or its replay in doubt', () => { + for (const recovered of [true, undefined] as const) { + const { entries, error } = answered(unknown('doubt', { recovered }), [ + entry('doubt', { state: 'dispatching', lastAttemptAt: 2 }), + entry('next') + ]) + expect(entries.map((candidate) => candidate.clientMessageId)).toEqual(['next']) + expect(error).toBeNull() + } + }) + + // Only this entry shows a message whose row the host lost, so it stays, as before. + it('stays when the host lost its row', () => { + const { entries } = answered(unknown('doubt', { reason: 'durable_send_submission_missing' }), [ + entry('doubt', { state: 'dispatching', lastAttemptAt: 2 }) + ]) + expect(entries).toMatchObject([{ state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 }]) + }) + + // A Retry asked of this very submission waits for the host's answer to it. + it('stays while the user retries that same submission', () => { + const retried = entry('doubt', { lastAttemptAt: 2, retryAfterUnknownSubmittedAt: 5 }) + expect(reconcileStructuredAgentSessionOutbox([retried], [unknown('doubt')])).toEqual([retried]) + }) +}) + +describe('a message the host may never have received', () => { + it('holds what follows it until the host answers it', () => { + const outbox = reconcileStructuredAgentSessionOutbox( + [entry('doubt', { state: 'unconfirmed', lastAttemptAt: 2 }), entry('next')], + // Another message's row, or the window of rows loaded after this one's aged out. + [unknown('other')] + ) + expect(admitStructuredAgentSessionOutboxEntry(outbox)).toEqual({ + state: 'blocked', + entry: outbox[0] + }) + // The probe's replay of it is the host's answer: it recorded it, and what follows goes. + const { entries } = answered(unknown('doubt'), outbox) + expect(admitStructuredAgentSessionOutboxEntry(entries)).toEqual({ + state: 'dispatch', + entry: entries[0] + }) + expect(entries.map((candidate) => candidate.clientMessageId)).toEqual(['next']) + }) +}) 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 7a6b10c4bf04..3e061e6447df 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -44,19 +44,17 @@ describe('a message held for its Retry', () => { 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({ + 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')], - [] - ) + admitStructuredAgentSessionOutboxEntry([ + held, + entry('doubt', { state: 'unconfirmed' }), + entry('next') + ]) ).toMatchObject({ state: 'blocked', entry: { clientMessageId: 'doubt' } }) }) @@ -80,7 +78,7 @@ describe('a message held for its Retry', () => { }) // Any fresh word on where a message stands supersedes an earlier attempt's failure. - it('is in doubt, and holds nothing up, once the host says it cannot tell whether it landed', () => { + it('leaves the outbox once the host records that it cannot tell whether it landed', () => { const submission: AgentJournalSubmission = { clientMessageId: 'held', fence: 1, @@ -95,9 +93,9 @@ describe('a message held for its Retry', () => { [entry('held', { lastAttemptAt: 2, lastFailure: REFUSED }), entry('next')], [submission] ) - expect(reconciled[0]).toMatchObject({ state: 'unconfirmed' }) - expect(reconciled[0]?.lastFailure).toBeUndefined() - expect(admitStructuredAgentSessionOutboxEntry(reconciled, [submission])).toMatchObject({ + // Its journal row draws it, and the host never sends it again, so nothing waits on it. + expect(reconciled.map((entry) => entry.clientMessageId)).toEqual(['next']) + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ state: 'dispatch', entry: { clientMessageId: 'next' } }) 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 ed5cc1e14404..958979d25c1d 100644 --- a/src/shared/structured-agent-session-outbox.queue-delivery.test.ts +++ b/src/shared/structured-agent-session-outbox.queue-delivery.test.ts @@ -113,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), [])).toEqual({ + expect(admitStructuredAgentSessionOutboxEntry(next.slice(2))).toEqual({ state: 'blocked', entry: next[2] }) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index ce030c3ab3c4..ab5d77e036b4 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -246,18 +246,14 @@ export function reconcileStructuredAgentSessionOutbox( } ] } + // Recorded, but whether the agent got it is unknown: its journal row draws it in place and the + // host never sends it again, so the entry goes. Only a Retry the user asked of this very + // submission stays, for its answer. if ( submission?.dispatchState === 'unknown' && - entry.retryAfterUnknownSubmittedAt !== -1 && entry.retryAfterUnknownSubmittedAt !== submission.submittedAt ) { - // In doubt now, not failed. The host has it, so it holds nothing up (admission). - if (entry.state === 'unconfirmed' && !entry.lastFailure) { - // Kept as is, so a streamed batch neither rewrites the saved outbox nor redraws it. - return [entry] - } - const { lastFailure: _superseded, ...inDoubt } = entry - return [{ ...inDoubt, state: 'unconfirmed' as const }] + return [] } return [entry] }) diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 62666191c02f..abc171c4ac23 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -68,6 +68,9 @@ function withLastFailure( return lastFailure === undefined ? rest : { ...rest, lastFailure } } +/** The host's replay of a send whose operation it recorded but whose submission row it lost. */ +const SUBMISSION_MISSING = 'durable_send_submission_missing' + function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEntry[] { return input.entries.filter( (candidate) => candidate.clientMessageId !== input.entry.clientMessageId @@ -286,15 +289,19 @@ export function disposeStructuredAgentSessionSendResult( error: null } } - if (submission.dispatchState === 'unknown' && submission.recovered) { - return { - entries: input.entries.map((candidate) => - candidate.clientMessageId === input.entry.clientMessageId - ? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 } - : candidate - ), - error: null - } + if (submission.dispatchState === 'unknown') { + // The host recorded it and never sends it again, so nothing waits on it: its row draws it, as + // the reconcile drops it. One whose row the host lost has only this entry to show it. + return submission.reason === SUBMISSION_MISSING + ? { + entries: input.entries.map((candidate) => + candidate.clientMessageId === input.entry.clientMessageId + ? { ...candidate, state: 'unconfirmed', retryAfterUnknownSubmittedAt: -1 } + : candidate + ), + error: null + } + : { entries: dropEntry(input), error: null } } // `pending` is the host saying the message was written and is awaiting the // provider's acknowledgement, which cannot arrive until the turn ahead of it @@ -303,10 +310,7 @@ export function disposeStructuredAgentSessionSendResult( // because a `pending` can still settle `rejected` or `unknown`, and only the // entry carries the retry state that answer needs. return { - entries: replaceEntryState( - input, - submission.dispatchState === 'unknown' ? 'unconfirmed' : 'dispatching' - ), + entries: replaceEntryState(input, 'dispatching'), error: null } } From 03ef70dda5d2ea9fccec2bf0b52daae2617b6046 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:16:12 -0700 Subject: [PATCH 06/15] test(native-chat): keep recorded-doubt hook tests together, under the outbox file's line limit --- ...nt-session-outbox-recorded-doubt.test.tsx} | 70 ++++++++++-- ...e-structured-agent-session-outbox.test.tsx | 102 +----------------- 2 files changed, 67 insertions(+), 105 deletions(-) rename src/renderer/src/components/native-chat/{use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx => use-structured-agent-session-outbox-recorded-doubt.test.tsx} (77%) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-recorded-doubt.test.tsx similarity index 77% rename from src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx rename to src/renderer/src/components/native-chat/use-structured-agent-session-outbox-recorded-doubt.test.tsx index 333412e3eabc..6d1108fb0d8f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-in-doubt-relaunch.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-recorded-doubt.test.tsx @@ -29,6 +29,7 @@ const SESSION = 'session-1' const LOCAL_TARGET = { kind: 'local' } as const type SendRequest = { envelope?: { clientOperationId?: string } } +type SentText = { body?: { blocks?: { text?: string }[] } } function requestId(params: SendRequest | undefined): string { return String(params?.envelope?.clientOperationId) @@ -67,6 +68,17 @@ const IN_DOUBT: AgentJournalSubmission = { recovered: true } +/** The host's answer to a send it recorded in doubt, first try or replay. */ +function inDoubt(clientMessageId: string) { + return { + ok: true, + replayed: clientMessageId === IN_DOUBT.clientMessageId, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 4 }, + value: { clientMessageId, submission: { ...IN_DOUBT, clientMessageId } } + } +} + function accepted(clientMessageId: string) { return { ok: true, @@ -103,13 +115,7 @@ describe('a queue saved behind a message the host holds in doubt', () => { mocks.call.mockImplementation((_target, _method, params: SendRequest) => Promise.resolve( requestId(params) === IN_DOUBT.clientMessageId - ? { - ok: true, - replayed: true, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 4 }, - value: { clientMessageId: IN_DOUBT.clientMessageId, submission: IN_DOUBT } - } + ? inDoubt(requestId(params)) : accepted(requestId(params)) ) ) @@ -236,4 +242,54 @@ describe('a queue saved behind a message the host holds in doubt', () => { rerender({ submissions: [IN_DOUBT] }) await waitFor(() => expect(sentIds()).toEqual(['op-next'])) }) + + it('drops a send the host answers in doubt, and sends what follows it at once', async () => { + mocks.call.mockImplementation((_target, _method, params: SendRequest & SentText) => + Promise.resolve( + params.body?.blocks?.[0]?.text === 'first' + ? inDoubt(requestId(params)) + : accepted(requestId(params)) + ) + ) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox).toEqual([])) + act(() => expect(result.current.send('second')).toBe(true)) + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(sentIds()).toHaveLength(2) + expect(result.current.error).toBeNull() + }) + + // A Retry an earlier build saved on this very submission waits for its answer: the host refuses + // to send it again, and the user is told to check the chat before sending it again. + it('drops a saved Retry of a recorded doubt once the host only replays it', async () => { + writeOutbox(SESSION, [ + saved(IN_DOUBT.clientMessageId, { + lastAttemptAt: 2, + retryAfterUnknownSubmittedAt: IN_DOUBT.submittedAt + }) + ]) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: LOCAL_TARGET, + fence: 1, + submissions: [IN_DOUBT] + }) + ) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(sentIds()).toEqual([IN_DOUBT.clientMessageId]) + expect(result.current.error).toBe( + "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." + ) + }) }) 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 a7cf45e8c5e4..6b52606fafbf 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 @@ -78,28 +78,6 @@ function acceptedResultFor(clientMessageId: string, fence: number) { } } -function unknownResultFor(clientMessageId: string, submittedAt: number) { - return { - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: submittedAt }, - value: { - clientMessageId, - submission: { - clientMessageId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'unknown' as const, - providerItemId: null, - reason: 'socket closed', - submittedAt, - resolvedAt: submittedAt - } - } - } -} - function pendingResultFor(clientMessageId: string, submittedAt: number) { return { ok: true, @@ -456,78 +434,6 @@ describe('useStructuredAgentSessionOutbox', () => { expect(mocks.call).toHaveBeenCalledOnce() }) - it('drops a head the host answers in doubt, and sends what follows it at once', async () => { - // The host recorded it and never sends it again, so nothing waits on it and nothing is said. - mocks.call.mockImplementation(async (_target, _method, params) => { - const request = params as { - envelope: { clientOperationId: string } - body: { blocks: { text?: string }[] } - } - return request.body.blocks[0]?.text === 'second' - ? pendingResultFor(request.envelope.clientOperationId, 20) - : unknownResultFor(request.envelope.clientOperationId, 10) - }) - const sentTexts = () => - mocks.call.mock.calls.map( - (call) => (call[2] as { body?: { blocks?: { text?: string }[] } })?.body?.blocks?.[0]?.text - ) - const { result } = renderHook(() => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions: [] - }) - ) - - act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - act(() => expect(result.current.send('second')).toBe(true)) - await waitFor(() => expect(sentTexts()).toEqual(['first', 'second'])) - expect(result.current.error).toBeNull() - }) - - it('drops a Retry an earlier build saved on a recorded doubt once the host only replays it', async () => { - // A Retry asked of this very submission stays for its answer: the host refuses to send it again, - // and the user is told to check the chat before sending it again. - const recorded = unknownResultFor('op-retried', 10).value.submission - localStorage.setItem( - 'orca:desktopStructuredAgentSessionOutbox:v1:session-1', - JSON.stringify([ - { - clientMessageId: 'op-retried', - sessionId: 'session-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'retried' }] }, - previewUris: [], - state: 'queued', - queuedAt: 1, - lastAttemptAt: 2, - retryAfterUnknownSubmittedAt: recorded.submittedAt - } - ]) - ) - mocks.call.mockImplementation(async (_target, _method, params) => - unknownResultFor( - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId, - 10 - ) - ) - const { result } = renderHook(() => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions: [recorded] - }) - ) - - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - expect(mocks.call).toHaveBeenCalledOnce() - expect(result.current.error).toBe( - "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed." - ) - }) - it('retains a send operation after a pending-admission refusal', async () => { mocks.call .mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale')) @@ -675,10 +581,10 @@ describe('useStructuredAgentSessionOutbox', () => { // The host recorded it and never sends it again: it goes, and the tail goes out unasked. await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - expect( - (mocks.call.mock.calls[1]?.[2] as { body: { blocks: { text?: string }[] } }).body.blocks[0] - ?.text - ).toBe('second') + const tail = mocks.call.mock.calls[1]?.[2] as + | { body?: { blocks?: { text?: string }[] } } + | undefined + expect(tail?.body?.blocks?.[0]?.text).toBe('second') }) it('rotates a history-rejected head in doubt so the queued tail can advance', async () => { From 49dd525ee61ff69d290933300f19bb33ab99e6dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:19:24 -0700 Subject: [PATCH 07/15] test(native-chat): read the tail's text without a cast --- .../native-chat/use-structured-agent-session-outbox.test.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx index 6b52606fafbf..22b011076c5c 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 @@ -581,10 +581,7 @@ describe('useStructuredAgentSessionOutbox', () => { // The host recorded it and never sends it again: it goes, and the tail goes out unasked. await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - const tail = mocks.call.mock.calls[1]?.[2] as - | { body?: { blocks?: { text?: string }[] } } - | undefined - expect(tail?.body?.blocks?.[0]?.text).toBe('second') + expect(mocks.call.mock.calls[1]?.[2]?.body?.blocks?.[0]?.text).toBe('second') }) it('rotates a history-rejected head in doubt so the queued tail can advance', async () => { From 427fc41bee87c16a77a88da6d48cc35fbb13535c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:38:32 -0700 Subject: [PATCH 08/15] fix(native-chat): a Retry's copy hides the row it replaced, later rejections stay visible in their own words, and the phone keeps hiding them --- ...ed-agent-session-undelivered-rows.test.tsx | 120 ++++++++++++++ .../use-mobile-structured-agent-session.ts | 4 +- ...ructured-agent-session-delivery-notices.ts | 31 ++-- ...ent-session-failed-start-elsewhere.test.ts | 92 +++++++++-- ...gent-session-outbox-retry-rotation.test.ts | 151 ++++++++++++++++++ .../structured-agent-session-outbox-retry.ts | 7 +- ...ed-agent-session-delivery-notices.test.tsx | 4 +- ...ed-agent-session-failed-start-elsewhere.ts | 20 ++- ...ctured-agent-session-message-projection.ts | 11 +- src/shared/structured-agent-session-outbox.ts | 21 ++- 10 files changed, 421 insertions(+), 40 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts diff --git a/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx new file mode 100644 index 000000000000..7f5d96953ff0 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx @@ -0,0 +1,120 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } +})) + +const MESSAGE: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('op-undelivered'), + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'never got it' }] } +} + +/** Handed over, then the agent's history showed it never got it. */ +const UNDELIVERED: AgentJournalSubmission = { + clientMessageId: 'op-undelivered', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + submittedAt: 1, + resolvedAt: 2, + handoverRecorded: true, + handedOverAt: 1, + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) +} + +function snapshot(): AgentSessionSubscribeEvent { + return { + type: 'snapshot', + sessionId: 'session-1', + fence: 3, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 3, + direction: 'tail', + items: [MESSAGE], + removedItemIds: [], + submissions: [UNDELIVERED], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 2 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 }, + hasOlder: false, + hasNewer: false + } + } +} + +// Module scope: the harness assigns these, which a test body's own `let` would narrow to null. +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +// The phone does not mark a message as unsent yet; drawn, it would look delivered. +it('keeps a message the agent never got hidden, as before', async () => { + const client: RpcClient = { + sendRequest: async () => ({ + id: 'request-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }), + subscribe: (_method, _params, onData) => { + listener = onData + return () => {} + }, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: 'session-1', + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport: null, + onSendError: vi.fn() + }) + return null + } + + await act(async () => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshot())) + + expect(hook?.session.messages).toEqual([]) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 88168db04e9b..736e8319d174 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -160,7 +160,9 @@ export function useMobileStructuredAgentSession(args: { const messages = useMemo( () => projectStructuredAgentSessionMessages(state.items, [], state.submissions, { - showsFailedStartsSentElsewhere: hostSupport?.retryMessage === true + showsFailedStartsSentElsewhere: hostSupport?.retryMessage === true, + // The phone does not mark a message as unsent yet, so these stay hidden rather than look sent. + showsUndeliveredSentElsewhere: false }), [state.items, state.submissions, hostSupport?.retryMessage] ) 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 1572d02a53c2..967850495a1b 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 @@ -11,8 +11,8 @@ // the message keeps only a smaller copy, read when its submission is not loaded. A message whose // start was refused before it ran and that waits for its next try says why and that Orca tries // again, whoever sent it. A rejection that is a failed start's, the fact a loaded start row from an older host -// states, says only that it was not sent: the row already says why. One the agent's history showed -// it never got says only that it was not sent, whoever sent it. +// states, says only that it was not sent: the row already says why. One rejected after it was +// handed over says why, whoever sent it. import { readAgentSessionFailureFact, @@ -28,6 +28,7 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session- import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionEntryIdExpired, + structuredAgentSessionRejectedFailure, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { @@ -143,13 +144,6 @@ function rejectionStatedByItsStartRow( ) } -function notSentText(): string { - return translate( - 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', - 'Message was not sent.' - ) -} - function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, @@ -167,7 +161,10 @@ function deliveryNoticeText( ) } if (!entry.lastFailure) { - return notSentText() + return translate( + 'auto.components.native.chat.NativeChatStructuredSession.93ef441197', + 'Message was not sent.' + ) } // An earlier attempt under the id the host forgot may already be in the chat. if (structuredAgentSessionEntryIdExpired(entry)) { @@ -265,9 +262,17 @@ export function structuredAgentSessionDeliveryNotices( ...retryControlFor(clientMessageId) }) } - // The host cannot queue it again, so there is no Retry: the person sends it anew. - for (const { clientMessageId } of undeliveredSentElsewhere(submissions, outbox)) { - notices.set(agentJournalSubmissionKey(clientMessageId), { text: notSentText() }) + // Worded as this client's own copy would be, with no Retry: the host cannot queue it again. + for (const submission of undeliveredSentElsewhere(submissions, outbox)) { + notices.set(agentJournalSubmissionKey(submission.clientMessageId), { + text: agentSessionWriteNoticeText( + structuredAgentSessionAttemptFailureParts( + structuredAgentSessionRejectedFailure(submission), + { agentName, retryControl: false }, + readWholeAgentSessionFailureFact(submission.rejection) + ) + ) + }) } return notices } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts index ad04413a3cb3..3cbd3a527cd5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts @@ -2,7 +2,10 @@ // orchestration worker's first — whose start failed for good still shows in the chat, saying why. import { describe, expect, it, vi } from 'vitest' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { + agentSessionFailureSentence, + agentSessionFailureWords +} from '../../../../shared/agent-session-failure-words' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalMessageItem, @@ -184,9 +187,20 @@ describe('a message sent from elsewhere whose start failed for good', () => { expect(projectStructuredAgentSessionMessages([TEXT], [], [card])).toEqual([]) }) - it('is never drawn for one an agent already took', () => { + // Its Retry can't queue a message an agent already took; it is drawn as any rejection after a + // hand-over is, with its own words and no Retry. + it('is drawn with no Retry once an agent already took it', () => { const handedOver = { ...rejected({ kind: 'providerStartFailed' }), handedOverAt: 5 } - expect(projectStructuredAgentSessionMessages([TEXT], [], [handedOver])).toEqual([]) + expect(projectStructuredAgentSessionMessages([TEXT], [], [handedOver])).toEqual([ + expect.objectContaining({ id: KEY, unsent: true }) + ]) + expect( + structuredAgentSessionDeliveryNotices([], 'Codex', vi.fn(), [handedOver], [], new Set()).get( + KEY + ) + ).toEqual({ + text: 'Codex stopped before it finished starting. Send your message to try again.' + }) }) }) @@ -231,16 +245,28 @@ describe('a message whose start failed for good, queued again by its Retry', () }) }) -// Handed over, then the agent's history showed it never got it. No outbox entry shows it — this -// client let go once the host recorded it in doubt, or another device sent it — so the journal does. -describe('a message handed over and then shown never delivered', () => { +// Rejected after it was handed over, or shown by the agent's history never to have reached it. No +// outbox entry shows it — this client let go once the host recorded it in doubt, or another device +// sent it — so the journal does, with its own words and no Retry. +describe('a message rejected after it was handed over', () => { const undelivered = (): AgentJournalSubmission => ({ ...rejected({ kind: 'notDelivered' }), handedOverAt: 5, recovered: true }) - it('shows as unsent with no Retry, on any host', () => { + function noticeOf(submissions: AgentJournalSubmission[]) { + return structuredAgentSessionDeliveryNotices( + [], + 'Claude', + vi.fn(), + submissions, + [], + new Set() + ).get(KEY) + } + + it('shows as unsent with no Retry, on any desktop host', () => { const submissions = [undelivered()] for (const showsFailedStartsSentElsewhere of [true, false]) { expect( @@ -250,13 +276,57 @@ describe('a message handed over and then shown never delivered', () => { ).toEqual([expect.objectContaining({ id: KEY, unsent: true })]) } // The host cannot queue a message it handed over again, so the person sends it anew. + expect(noticeOf(submissions)).toEqual({ + text: 'This message was not delivered. Send it again to continue.' + }) + }) + + // The phone does not mark a message as unsent yet: drawn there, it would look delivered. + it('stays hidden on a surface that cannot mark it unsent', () => { expect( - structuredAgentSessionDeliveryNotices([], 'Claude', vi.fn(), submissions, [], new Set()).get( - KEY - ) - ).toEqual({ text: 'Message was not sent.' }) + projectStructuredAgentSessionMessages([TEXT], [], [undelivered()], { + showsUndeliveredSentElsewhere: false + }) + ).toEqual([]) }) + // A doubt the host settled later, after this client let its entry go: the agent refused it, or a + // Stop withdrew it. + it('says why when the agent refused it after its doubt, and stays hidden when withdrawn', () => { + const dispatching = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: ID, + sessionId: 'session-1', + text: 'Continue where you left off', + attachments: [], + queuedAt: 4 + }), + state: 'dispatching' as const + } + const inDoubt: AgentJournalSubmission = { + ...undelivered(), + dispatchState: 'unknown', + reason: 'provider_write_outcome_unknown: EPIPE', + rejection: undefined + } + expect(reconcileStructuredAgentSessionOutbox([dispatching], [inDoubt])).toEqual([]) + + const refused = { ...undelivered(), ...rejected({ kind: 'providerRejected' }), handedOverAt: 5 } + expect(projectStructuredAgentSessionMessages([TEXT], [], [refused])).toEqual([ + expect.objectContaining({ id: KEY, unsent: true }) + ]) + expect(noticeOf([refused])?.text).toBe( + agentSessionFailureSentence({ kind: 'providerRejected' }, 'rejection', { + agentName: 'Claude', + retryControl: false + }) + ) + expect(noticeOf([refused])?.onRetry).toBeUndefined() + + const withdrawn = { ...undelivered(), ...rejected({ kind: 'cancelled' }), handedOverAt: 5 } + expect(projectStructuredAgentSessionMessages([TEXT], [], [withdrawn])).toEqual([]) + expect(noticeOf([withdrawn])).toBeUndefined() + }) it('is drawn once by the entry of the client that still holds it', () => { const own = reconcileStructuredAgentSessionOutbox( [ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts new file mode 100644 index 000000000000..4312a7907ad3 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +// A Retry on this client's own message the agent never got sends it again under a new id. The new +// copy is the message: the old row is never drawn beside it, whatever becomes of the copy, and a +// reopen reads the same from the saved outbox. + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { + createStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' +import { + getStructuredAgentSessionOutbox, + readOutbox, + writeOutbox +} from './structured-agent-session-outbox-storage' + +const SESSION = 'session-1' +const ORIGINAL = 'op-original' +const COPY = 'op-copy' + +const ITEM: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey(ORIGINAL), + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } +} + +const UNDELIVERED: AgentJournalSubmission = { + clientMessageId: ORIGINAL, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + submittedAt: 4, + resolvedAt: 7, + handoverRecorded: true, + handedOverAt: 5, + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) +} + +function copyRow(patch: Partial): AgentJournalSubmission { + return { + ...UNDELIVERED, + clientMessageId: COPY, + dispatchState: 'pending', + reason: null, + rejection: undefined, + submittedAt: 9, + resolvedAt: null, + handedOverAt: undefined, + recovered: undefined, + ...patch + } +} + +/** What the chat draws: each row's id, and the ids that carry a notice. */ +function drawn( + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: AgentJournalSubmission[] +) { + return { + rows: projectStructuredAgentSessionMessages([ITEM], outbox, submissions).map(({ id }) => id), + noticed: [ + ...structuredAgentSessionDeliveryNotices( + outbox, + 'Claude', + vi.fn(), + submissions, + [], + new Set() + ).keys() + ] + } +} + +describe("a Retry on this client's own message the agent never got", () => { + let afterRetry: StructuredAgentSessionOutboxEntry[] + + beforeEach(() => { + localStorage.clear() + const own = reconcileStructuredAgentSessionOutbox( + [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: ORIGINAL, + sessionId: SESSION, + text: 'hello', + attachments: [], + queuedAt: 4 + }), + state: 'dispatching', + lastAttemptAt: 4 + } + ], + [UNDELIVERED] + ) + writeOutbox(SESSION, own) + retryStructuredAgentSessionOutboxEntry({ + clientMessageId: ORIGINAL, + sessionId: SESSION, + submissions: [UNDELIVERED], + setError: vi.fn(), + createOperationId: () => COPY + }) + afterRetry = getStructuredAgentSessionOutbox(SESSION) + }) + + it('draws only the copy while it waits to go out, and after a reopen', () => { + expect(afterRetry).toMatchObject([{ clientMessageId: COPY, rotatedFrom: [ORIGINAL] }]) + const copyKey = agentJournalSubmissionKey(COPY) + expect(drawn(afterRetry, [UNDELIVERED])).toEqual({ rows: [copyKey], noticed: [] }) + // The saved outbox remembers the id it replaced. + expect(drawn(readOutbox(SESSION), [UNDELIVERED])).toEqual({ rows: [copyKey], noticed: [] }) + }) + + it.each<[string, Partial]>([ + ['recorded and waiting behind a turn', {}], + ['handed over', { handedOverAt: 10 }] + ])('never draws the old row beside the copy once it is %s', (_case, patch) => { + const submissions = [UNDELIVERED, copyRow(patch)] + const outbox = reconcileStructuredAgentSessionOutbox(afterRetry, submissions) + expect(drawn(outbox, submissions).noticed).toEqual([]) + expect(drawn(outbox, submissions).rows).not.toContain(agentJournalSubmissionKey(ORIGINAL)) + }) + + it('never draws the old row beside a copy the host refused', () => { + const refused = afterRetry.map((entry) => ({ + ...entry, + lastAttemptAt: 9, + lastFailure: { kind: 'refused' as const, code: 'agent_session_journal_unreadable' as const } + })) + expect(drawn(refused, [UNDELIVERED])).toEqual({ + rows: [agentJournalSubmissionKey(COPY)], + noticed: [agentJournalSubmissionKey(COPY)] + }) + }) +}) 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 73619e717591..640d039370be 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 @@ -3,6 +3,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { + rotateStructuredAgentSessionOutboxEntryId, structuredAgentSessionEntryIdExpired, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' @@ -40,8 +41,10 @@ export function retryStructuredAgentSessionOutboxEntry(args: { const rotated = outbox.map((entry) => entry.clientMessageId === clientMessageId ? { - ...retriedByUser(entry), - clientMessageId: args.createOperationId(), + ...rotateStructuredAgentSessionOutboxEntryId( + retriedByUser(entry), + args.createOperationId() + ), state: 'queued' as const, lastAttemptAt: null, retryAfterUnknownSubmittedAt: null diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx index e94444e4a350..0abb09a4aa29 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx @@ -13,7 +13,7 @@ import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent afterEach(cleanup) -it('says a message the agent never got was not sent, with no Retry, on a client with no entry', () => { +it('says a message the agent never got was not delivered, with no Retry, on a client with no entry', () => { const undelivered: AgentJournalSubmission = { clientMessageId: 'op-undelivered', fence: 1, @@ -42,6 +42,6 @@ it('says a message the agent never got was not sent, with no Retry, on a client ) expect(result.current.get(agentJournalSubmissionKey('op-undelivered'))).toEqual({ - text: 'Message was not sent.' + text: 'This message was not delivered. Send it again to continue.' }) }) diff --git a/src/shared/structured-agent-session-failed-start-elsewhere.ts b/src/shared/structured-agent-session-failed-start-elsewhere.ts index e1f3018f66fc..1ae20a9e3e08 100644 --- a/src/shared/structured-agent-session-failed-start-elsewhere.ts +++ b/src/shared/structured-agent-session-failed-start-elsewhere.ts @@ -2,17 +2,22 @@ // continuation, one another device sent, or one this client let go once the host recorded it — // that never reached its agent. The chat shows it from the journal as an unsent message. // One whose start failed for good, on a host that can queue it again, says why, and its Retry -// queues that same message again. One the agent's own history later showed it never got says -// only that it was not sent, with no Retry: the host cannot queue it again. +// queues that same message again. One rejected after it was handed over (or that the agent's own +// history showed it never got) says why, with no Retry: the host cannot queue it again, and the +// agent may hold it already, so only the person sends it anew. import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { classifyDispatchRejection, + dispatchWasWithdrawn, failedBeforeHandover } from './structured-agent-session-dispatch-rejection' -type SentHere = readonly Pick[] +type SentHere = readonly Pick< + StructuredAgentSessionOutboxEntry, + 'clientMessageId' | 'rotatedFrom' +>[] export function failedStartsSentElsewhere( submissions: readonly AgentJournalSubmission[], @@ -30,7 +35,9 @@ export function undeliveredSentElsewhere( outbox, (submission) => submission.dispatchState === 'rejected' && - classifyDispatchRejection(submission).kind === 'notDelivered' + !dispatchWasWithdrawn(submission) && + (submission.handedOverAt !== undefined || + classifyDispatchRejection(submission).kind === 'notDelivered') ) } @@ -39,7 +46,10 @@ function unsentElsewhere( outbox: SentHere, unsent: (submission: AgentJournalSubmission) => boolean ): AgentJournalSubmission[] { - const sentHere = new Set(outbox.map((entry) => entry.clientMessageId)) + // A message sent again here under a new id is still this client's: its old rows are not drawn. + const sentHere = new Set( + outbox.flatMap((entry) => [entry.clientMessageId, ...(entry.rotatedFrom ?? [])]) + ) return submissions.filter( (submission) => !sentHere.has(submission.clientMessageId) && diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 67386bba3ca3..61bf4683965c 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -20,11 +20,14 @@ export function projectStructuredAgentSessionMessages( submissions: readonly AgentJournalSubmission[], { projectItems = projectStructuredItemsToNativeChat, - showsFailedStartsSentElsewhere = true + showsFailedStartsSentElsewhere = true, + showsUndeliveredSentElsewhere = true }: { projectItems?: typeof projectStructuredItemsToNativeChat /** Whether the host can queue one again: an older host's are left hidden, as before. */ showsFailedStartsSentElsewhere?: boolean + /** Whether the surface marks a message as unsent; one that can't leaves these hidden. */ + showsUndeliveredSentElsewhere?: boolean } = {} ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) @@ -34,12 +37,12 @@ export function projectStructuredAgentSessionMessages( .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) - // Sent from elsewhere and refused for good by a failed start, or shown by the agent's history never - // to have reached it: shown as unsent, as this client's own would be, where the journal put it. + // Sent from elsewhere and refused for good by a failed start, or rejected after it was handed + // over: shown as unsent, as this client's own would be, where the journal put it. const unsentElsewhere = new Set( [ ...(showsFailedStartsSentElsewhere ? failedStartsSentElsewhere(submissions, outbox) : []), - ...undeliveredSentElsewhere(submissions, outbox) + ...(showsUndeliveredSentElsewhere ? undeliveredSentElsewhere(submissions, outbox) : []) ].map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const visibleItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index ab5d77e036b4..7e372d1457ff 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -45,6 +45,9 @@ export type StructuredAgentSessionOutboxEntry = { * 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 + /** The ids this message went out under before a new one replaced them, oldest first: the + * journal's rows under them are this message's, so the chat never draws them beside it. */ + rotatedFrom?: string[] } /** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not @@ -175,6 +178,18 @@ export function structuredAgentSessionEntryIdExpired( ) } +/** The message under a new id, remembering the one it replaces. */ +export function rotateStructuredAgentSessionOutboxEntryId( + entry: StructuredAgentSessionOutboxEntry, + clientMessageId: string +): StructuredAgentSessionOutboxEntry { + return { + ...entry, + clientMessageId, + rotatedFrom: [...(entry.rotatedFrom ?? []), entry.clientMessageId] + } +} + export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, refusal: AgentSessionWriteRefusal, @@ -199,8 +214,7 @@ export function requeueStructuredAgentSessionSendRefusal( // doubt, may have landed, so those keep it. Only a settled refusal proves the message never // landed. return { - ...entry, - clientMessageId: createOperationId(), + ...rotateStructuredAgentSessionOutboxEntryId(entry, createOperationId()), state: refusalSettled ? 'rejected' : 'queued', lastAttemptAt: null, retryAfterUnknownSubmittedAt: null @@ -297,6 +311,9 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...(Array.isArray(entry.rotatedFrom) && entry.rotatedFrom.every((id) => typeof id === 'string') + ? { rotatedFrom: entry.rotatedFrom } + : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From 52fe0f6345949451119c6653237a1b3e873970d0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:42:43 -0700 Subject: [PATCH 09/15] refactor(native-chat): the outbox's id rotation in its own module, under the outbox file's line limit --- .../structured-agent-session-outbox-retry.ts | 2 +- ...tructured-agent-session-outbox-rotation.ts | 26 +++++++++++++++++++ src/shared/structured-agent-session-outbox.ts | 24 +++++------------ 3 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 src/shared/structured-agent-session-outbox-rotation.ts 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 640d039370be..dce0aad5b27b 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,8 +2,8 @@ // id when the recorded one can only ever replay a settled rejection. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { rotateStructuredAgentSessionOutboxEntryId } from '../../../../shared/structured-agent-session-outbox-rotation' import { - rotateStructuredAgentSessionOutboxEntryId, structuredAgentSessionEntryIdExpired, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' diff --git a/src/shared/structured-agent-session-outbox-rotation.ts b/src/shared/structured-agent-session-outbox-rotation.ts new file mode 100644 index 000000000000..bb68efcb5760 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-rotation.ts @@ -0,0 +1,26 @@ +// A message the outbox sends again under a new id keeps the ids it replaced: the journal's rows +// under them are this message's, so the chat never draws them beside it. + +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' + +/** The message under a new id, remembering the one it replaces. */ +export function rotateStructuredAgentSessionOutboxEntryId( + entry: StructuredAgentSessionOutboxEntry, + clientMessageId: string +): StructuredAgentSessionOutboxEntry { + return { + ...entry, + clientMessageId, + rotatedFrom: [...(entry.rotatedFrom ?? []), entry.clientMessageId] + } +} + +/** The replaced ids a saved entry carries; a malformed list is dropped. */ +export function parseStructuredAgentSessionOutboxRotation(entry: { + rotatedFrom?: unknown +}): Pick { + const { rotatedFrom } = entry + return Array.isArray(rotatedFrom) && rotatedFrom.every((id) => typeof id === 'string') + ? { rotatedFrom } + : {} +} diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 7e372d1457ff..391de936698d 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -14,6 +14,10 @@ import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' +import { + parseStructuredAgentSessionOutboxRotation, + rotateStructuredAgentSessionOutboxEntryId +} from './structured-agent-session-outbox-rotation' /** `rejected`: the host settled the send as not delivered. The drain never sends it again on its * own and nothing queues behind it; only the user's Retry does, as a new id, or, on a host that @@ -45,8 +49,8 @@ export type StructuredAgentSessionOutboxEntry = { * 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 - /** The ids this message went out under before a new one replaced them, oldest first: the - * journal's rows under them are this message's, so the chat never draws them beside it. */ + /** The ids this message went out under before a new one replaced them, oldest first + * (structured-agent-session-outbox-rotation). */ rotatedFrom?: string[] } @@ -178,18 +182,6 @@ export function structuredAgentSessionEntryIdExpired( ) } -/** The message under a new id, remembering the one it replaces. */ -export function rotateStructuredAgentSessionOutboxEntryId( - entry: StructuredAgentSessionOutboxEntry, - clientMessageId: string -): StructuredAgentSessionOutboxEntry { - return { - ...entry, - clientMessageId, - rotatedFrom: [...(entry.rotatedFrom ?? []), entry.clientMessageId] - } -} - export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, refusal: AgentSessionWriteRefusal, @@ -311,9 +303,7 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), - ...(Array.isArray(entry.rotatedFrom) && entry.rotatedFrom.every((id) => typeof id === 'string') - ? { rotatedFrom: entry.rotatedFrom } - : {}), + ...parseStructuredAgentSessionOutboxRotation(entry), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From 0186c257be5a613f4756d72ac022494d4a835df7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:47:53 -0700 Subject: [PATCH 10/15] test(native-chat): a Codex Stop whose interrupt failed or went unanswered, ended the child or not, or proved its exit late, never holds the next message --- ...tured-agent-session-codex-stop-row.test.ts | 169 ++++++++++++++---- 1 file changed, 132 insertions(+), 37 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 0ee7ba9ef961..648a1a9b7b02 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -486,68 +486,163 @@ describe('a message after a Codex Stop whose exit was unproven', () => { }) }) -// The follow-up may already be with Codex, so the host keeps it in doubt; it never holds the -// desktop's next message, which goes to a fresh Codex. -describe('a follow-up Codex never echoed, when a Stop whose interrupt failed ends the child', () => { - it('stays in doubt, and the message queued behind it goes out', async () => { +// A follow-up steered into an open Codex turn and not yet echoed, when a Stop's interrupt fails. +// The desktop's outbox is fed the host's own rows, as its reconcile and drain read them: nothing +// the host keeps in doubt may hold the next message, which goes to a fresh Codex when the Stop +// ended the child, or to the same one when it did not. +describe('a follow-up Codex never echoed, when a Stop whose interrupt failed', () => { + /** A running turn, then a follow-up Codex took as a steer and has not echoed. */ + async function steeredFollowUp(): Promise { await runningTurn() codex.routes['turn/steer'] = turns.routes['turn/steer'] const followUp = await send('and also this') if (!followUp.ok) { throw new Error(JSON.stringify(followUp.refusal)) } - const followUpId = followUp.value.clientMessageId await vi.waitFor(() => expect(codex.connections.at(-1)!.calls.some((call) => call.method === 'turn/steer')).toBe( true ) ) - codex.routes['turn/interrupt'] = () => { - throw interruptFailure('internal error') - } + return followUp.value.clientMessageId + } - await stop() - await host.flushStreamedEvents(SESSION) - expect(childEndedByStop()).toBe(true) - const { submissions } = await host.journalSnapshot(SESSION) - expect(submissions.find((row) => row.clientMessageId === followUpId)).toMatchObject({ - dispatchState: 'unknown', - recovered: true + function queued(clientMessageId: string, text: string) { + return createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: SESSION, + text, + attachments: [], + queuedAt: NOW }) + } - // The desktop's outbox as it held them: the follow-up on its way, the next message queued. The - // follow-up's entry goes, as its journal row draws it. - const queued = (clientMessageId: string, text: string) => - createStructuredAgentSessionOutboxEntry({ - clientMessageId, - sessionId: SESSION, - text, - attachments: [], - queuedAt: NOW - }) + async function followUpRow(followUpId: string) { + const { submissions } = await host.journalSnapshot(SESSION) + return submissions.find((row) => row.clientMessageId === followUpId) + } + + /** The desktop's queue as it held them, the follow-up on its way, read through the host's rows: + * the next message is admitted and reaches Codex as a turn of its own. Resolves with the queue + * as it stands once that message is on its way. */ + async function nextMessageGoesOut(followUpId: string, child: number) { + const { submissions } = await host.journalSnapshot(SESSION) const next = queued(hostTestOperationId(), 'carry on') const outbox = reconcileStructuredAgentSessionOutbox( [{ ...queued(followUpId, 'and also this'), state: 'dispatching', lastAttemptAt: NOW }, next], submissions ) - expect(outbox).toEqual([next]) - const admission = admitStructuredAgentSessionOutboxEntry(outbox) - expect(admission).toEqual({ state: 'dispatch', entry: next }) - if (admission.state !== 'dispatch') { - return - } - + expect(admitStructuredAgentSessionOutboxEntry(outbox)).toEqual({ + state: 'dispatch', + entry: next + }) launch.resumeThreadId = THREAD - expect( - await host.send(CALLER, structuredAgentSessionSendMutation(admission.entry, 1)) - ).toMatchObject({ ok: true }) - await vi.waitFor(() => expect(codex.connections).toHaveLength(2)) + expect(await host.send(CALLER, structuredAgentSessionSendMutation(next, 1))).toMatchObject({ + ok: true + }) + await vi.waitFor(() => expect(codex.connections).toHaveLength(child + 1)) await vi.waitFor(() => expect( - codex.connections[1]!.calls.some( + codex.connections[child]!.calls.some( (call) => call.method === 'turn/start' && JSON.stringify(call.params).includes('carry on') ) ).toBe(true) ) + return outbox.map((entry) => + entry === next ? { ...entry, state: 'dispatching' as const, lastAttemptAt: NOW } : entry + ) + } + + /** Read through the host's rows as they stand now, the queue admits what is sent after. */ + async function queueStaysOpen(outbox: ReturnType) { + const { submissions } = await host.journalSnapshot(SESSION) + const later = queued(hostTestOperationId(), 'and then') + expect( + admitStructuredAgentSessionOutboxEntry( + reconcileStructuredAgentSessionOutbox([...outbox, later], submissions) + ) + ).toEqual({ state: 'dispatch', entry: later }) + } + + /** As the real connection: once a close begins it refuses every request, proven or not. The + * first close does not prove the exit. */ + function firstCloseUnproven(): void { + const old = codex.connections.at(-1)! + const close = old.close + const request = old.request + let unproven = 1 + old.close = async () => { + old.closed = true + if (unproven === 0) { + return close() + } + unproven -= 1 + return false + } + old.request = (method, params) => + old.closed + ? Promise.reject(new Error('codex app-server is closing')) + : request(method, params) + } + + it.each(['internal error', 'unanswered'] as const)( + 'stays in doubt when the Stop ends the child (%s), and the next message goes to a fresh Codex', + async (failure) => { + const followUpId = await steeredFollowUp() + codex.routes['turn/interrupt'] = () => { + throw interruptFailure(failure) + } + + await stop() + await host.flushStreamedEvents(SESSION) + expect(childEndedByStop()).toBe(true) + expect(await followUpRow(followUpId)).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + await queueStaysOpen(await nextMessageGoesOut(followUpId, 1)) + } + ) + + // Codex marks the turn ended before it writes the refusal, so the turn's end follows it. + it('keeps the child when Codex had no turn to interrupt, and the next message goes to it', async () => { + const followUpId = await steeredFollowUp() + codex.routes['turn/interrupt'] = () => { + setTimeout(() => turns.end('completed'), 2) + throw refused('no active turn to interrupt') + } + + await stop() + await vi.waitFor(async () => expect((await journalRows()).turns).toEqual(['completed'])) + await host.flushStreamedEvents(SESSION) + expect(childEndedByStop()).toBe(false) + // Nothing settled it, so the desktop's entry stays on its way, which holds nothing up. + expect(await followUpRow(followUpId)).toMatchObject({ dispatchState: 'pending' }) + await queueStaysOpen(await nextMessageGoesOut(followUpId, 0)) }) + + // The owed stop is proven by the next send, which settles the follow-up in doubt only then. + it.each(['internal error', 'unanswered'] as const)( + 'stays in doubt once a Stop that could not prove the exit (%s) is proven, and the queue never stops', + async (failure) => { + const followUpId = await steeredFollowUp() + codex.routes['turn/interrupt'] = () => { + throw interruptFailure(failure) + } + firstCloseUnproven() + + await stop() + await host.flushStreamedEvents(SESSION) + expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeDefined() + expect(await followUpRow(followUpId)).toMatchObject({ dispatchState: 'pending' }) + + const outbox = await nextMessageGoesOut(followUpId, 1) + expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() + expect(await followUpRow(followUpId)).toMatchObject({ + dispatchState: 'unknown', + recovered: true + }) + await queueStaysOpen(outbox) + } + ) }) From 6e4bc054b3dd06ebc8903d98554f62118e81b832 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:55:36 -0700 Subject: [PATCH 11/15] fix(native-chat): a Retry's copy goes out at once, never into a card beside the message it replaces --- ...ent-session-failed-start-elsewhere.test.ts | 6 +- ...ctured-agent-session-message-projection.ts | 6 +- ...gent-session-outbox-retry-rotation.test.ts | 73 ++++++++++++++++--- .../use-structured-agent-session-messages.ts | 9 ++- .../use-structured-agent-session.ts | 3 +- ...ed-agent-session-failed-start-elsewhere.ts | 4 +- ...ctured-agent-session-message-projection.ts | 10 ++- ...tructured-agent-session-outbox-delivery.ts | 4 +- 8 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts index 3cbd3a527cd5..9b120270ca74 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts @@ -187,9 +187,9 @@ describe('a message sent from elsewhere whose start failed for good', () => { expect(projectStructuredAgentSessionMessages([TEXT], [], [card])).toEqual([]) }) - // Its Retry can't queue a message an agent already took; it is drawn as any rejection after a - // hand-over is, with its own words and no Retry. - it('is drawn with no Retry once an agent already took it', () => { + // Only an older host's rows: a current one never keeps a failed start's hand-over. Its Retry can't + // queue a message an agent already took; drawn with its own words and no Retry. + it("is drawn with no Retry once an agent already took it, in an older host's rows", () => { const handedOver = { ...rejected({ kind: 'providerStartFailed' }), handedOverAt: 5 } expect(projectStructuredAgentSessionMessages([TEXT], [], [handedOver])).toEqual([ expect.objectContaining({ id: KEY, unsent: true }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index 09bcc77f5147..7544ba17a963 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -10,11 +10,13 @@ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], - showsFailedStartsSentElsewhere: boolean + showsFailedStartsSentElsewhere: boolean, + sentHere: readonly StructuredAgentSessionOutboxEntry[] = outbox ) { return projectMessages(items, outbox, submissions, { projectItems: projectStructuredQuestionMessages, - showsFailedStartsSentElsewhere + showsFailedStartsSentElsewhere, + sentHere }) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts index 4312a7907ad3..d06dcfa83391 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts @@ -1,8 +1,9 @@ // @vitest-environment happy-dom -// A Retry on this client's own message the agent never got sends it again under a new id. The new -// copy is the message: the old row is never drawn beside it, whatever becomes of the copy, and a -// reopen reads the same from the saved outbox. +// A Retry on this client's own message the agent never got sends it again under a new id, at once, +// even while the agent works with queued follow-ups on. The new copy is the message: the old row is +// never drawn beside it, whatever becomes of the copy, and a reopen reads the same from the saved +// outbox. Drawn as the chat draws it: the transcript leaves out sends shown as queued cards. import { beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' @@ -18,7 +19,10 @@ import { reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryAttempt } from '../../../../shared/structured-agent-session-outbox-delivery' +import { disposeStructuredAgentSessionSendResult } from '../../../../shared/structured-agent-session-send-disposition' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' import { getStructuredAgentSessionOutbox, @@ -27,6 +31,8 @@ import { } from './structured-agent-session-outbox-storage' const SESSION = 'session-1' +// The agent is working and the host queues follow-ups, as the default setting has it. +const QUEUEING = { capability: 'supported', enabled: true } as const const ORIGINAL = 'op-original' const COPY = 'op-copy' @@ -73,7 +79,12 @@ function drawn( submissions: AgentJournalSubmission[] ) { return { - rows: projectStructuredAgentSessionMessages([ITEM], outbox, submissions).map(({ id }) => id), + rows: projectStructuredAgentSessionMessages( + [ITEM], + outboxOutsideQueuedCards(outbox, [], true, QUEUEING), + submissions, + { sentHere: outbox } + ).map(({ id }) => id), noticed: [ ...structuredAgentSessionDeliveryNotices( outbox, @@ -127,14 +138,52 @@ describe("a Retry on this client's own message the agent never got", () => { expect(drawn(readOutbox(SESSION), [UNDELIVERED])).toEqual({ rows: [copyKey], noticed: [] }) }) - it.each<[string, Partial]>([ - ['recorded and waiting behind a turn', {}], - ['handed over', { handedOverAt: 10 }] - ])('never draws the old row beside the copy once it is %s', (_case, patch) => { - const submissions = [UNDELIVERED, copyRow(patch)] - const outbox = reconcileStructuredAgentSessionOutbox(afterRetry, submissions) - expect(drawn(outbox, submissions).noticed).toEqual([]) - expect(drawn(outbox, submissions).rows).not.toContain(agentJournalSubmissionKey(ORIGINAL)) + /** The drain's attempt at the copy while the agent works, and the host's answer to what it sent: + * a queued card for a send that asks to be queued, else the copy steered into the turn. */ + function sendWhileWorking(patch: Partial) { + const { stored, wire } = structuredAgentSessionEntryAttempt(afterRetry[0]!, QUEUEING) + const inFlight = [{ ...stored, state: 'dispatching' as const, lastAttemptAt: 9 }] + const row = copyRow(patch) + const { entries } = disposeStructuredAgentSessionSendResult({ + entries: inFlight, + entry: inFlight[0]!, + result: { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 9 }, + value: + wire.sentDelivery === 'queue-if-active' + ? { clientMessageId: COPY, queued: { messageId: COPY, position: 1, state: 'waiting' } } + : { clientMessageId: COPY, submission: row } + }, + createOperationId: () => 'unused' + }) + return { entries, submissions: [UNDELIVERED, row] } + } + + it('sends the copy at once while the agent works, so it is never a card beside the old row', () => { + expect(structuredAgentSessionEntryAttempt(afterRetry[0]!, QUEUEING).wire.sentDelivery).toBe( + null + ) + const { entries, submissions } = sendWhileWorking({}) + expect(drawn(entries, submissions)).toEqual({ + rows: [agentJournalSubmissionKey(COPY)], + noticed: [] + }) + }) + + it('never draws the old row beside the copy once the copy is handed over', () => { + const { entries, submissions } = sendWhileWorking({ handedOverAt: 10 }) + expect(drawn(entries, submissions).noticed).toEqual([]) + expect(drawn(entries, submissions).rows).not.toContain(agentJournalSubmissionKey(ORIGINAL)) + }) + + // The transcript leaves out a send it draws as a card; the whole outbox still names the row. + it('hides the old row while the transcript leaves its copy out', () => { + expect( + projectStructuredAgentSessionMessages([ITEM], [], [UNDELIVERED], { sentHere: afterRetry }) + ).toEqual([]) }) it('never draws the old row beside a copy the host refused', () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts index 38b411bd6839..be7f166d53da 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts @@ -10,7 +10,9 @@ export function useStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], - showsFailedStartsSentElsewhere: boolean + showsFailedStartsSentElsewhere: boolean, + /** The whole outbox, where `outbox` leaves out the sends drawn as cards. */ + sentHere: readonly StructuredAgentSessionOutboxEntry[] = outbox ) { return useMemo( () => @@ -18,8 +20,9 @@ export function useStructuredAgentSessionMessages( items, outbox, submissions, - showsFailedStartsSentElsewhere + showsFailedStartsSentElsewhere, + sentHere ), - [items, outbox, submissions, showsFailedStartsSentElsewhere] + [items, outbox, submissions, showsFailedStartsSentElsewhere, sentHere] ) } 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 bea63e608580..68d6caea4315 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 @@ -178,7 +178,8 @@ export function useStructuredAgentSession(args: { transportState.journalItems, transcriptOutbox, submissions, - retriesInPlace + retriesInPlace, + outbox ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, diff --git a/src/shared/structured-agent-session-failed-start-elsewhere.ts b/src/shared/structured-agent-session-failed-start-elsewhere.ts index 1ae20a9e3e08..8302f637264b 100644 --- a/src/shared/structured-agent-session-failed-start-elsewhere.ts +++ b/src/shared/structured-agent-session-failed-start-elsewhere.ts @@ -3,8 +3,8 @@ // that never reached its agent. The chat shows it from the journal as an unsent message. // One whose start failed for good, on a host that can queue it again, says why, and its Retry // queues that same message again. One rejected after it was handed over (or that the agent's own -// history showed it never got) says why, with no Retry: the host cannot queue it again, and the -// agent may hold it already, so only the person sends it anew. +// history showed it never got) says why, with no Retry: the host re-arms only a message no agent +// took, and a Retry that sent a copy from every device showing it could send it twice. import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 61bf4683965c..af6265138413 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -21,13 +21,17 @@ export function projectStructuredAgentSessionMessages( { projectItems = projectStructuredItemsToNativeChat, showsFailedStartsSentElsewhere = true, - showsUndeliveredSentElsewhere = true + showsUndeliveredSentElsewhere = true, + sentHere = outbox }: { projectItems?: typeof projectStructuredItemsToNativeChat /** Whether the host can queue one again: an older host's are left hidden, as before. */ showsFailedStartsSentElsewhere?: boolean /** Whether the surface marks a message as unsent; one that can't leaves these hidden. */ showsUndeliveredSentElsewhere?: boolean + /** This client's whole outbox, cards' sends included, where `outbox` is only what the + * transcript draws: no row of a message it holds is drawn as sent elsewhere. */ + sentHere?: readonly Pick[] } = {} ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) @@ -41,8 +45,8 @@ export function projectStructuredAgentSessionMessages( // over: shown as unsent, as this client's own would be, where the journal put it. const unsentElsewhere = new Set( [ - ...(showsFailedStartsSentElsewhere ? failedStartsSentElsewhere(submissions, outbox) : []), - ...(showsUndeliveredSentElsewhere ? undeliveredSentElsewhere(submissions, outbox) : []) + ...(showsFailedStartsSentElsewhere ? failedStartsSentElsewhere(submissions, sentHere) : []), + ...(showsUndeliveredSentElsewhere ? undeliveredSentElsewhere(submissions, sentHere) : []) ].map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const visibleItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts index 28ced3d2fee7..604b8ff307d4 100644 --- a/src/shared/structured-agent-session-outbox-delivery.ts +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -20,7 +20,8 @@ export type StructuredAgentSessionQueueDelivery = { /** Whether this entry's next request asks to be queued. An attempted id asks exactly what it sent, * except of a host known not to queue, which rejects the field before its operation ledger, so * nothing there was recorded with it. A first attempt asks only of a host known to queue, with - * the setting on, for plain text that is not a launch prompt. */ + * the setting on, for plain text that is not a launch prompt nor a Retry's copy under a new id: + * a Retry goes now, as one in place does, never into a card beside the message it replaces. */ export function structuredAgentSessionEntryAsksToQueue( entry: StructuredAgentSessionOutboxEntry, host: StructuredAgentSessionQueueDelivery @@ -32,6 +33,7 @@ export function structuredAgentSessionEntryAsksToQueue( host.capability === 'supported' && host.enabled && entry.source !== 'launch' && + entry.rotatedFrom === undefined && entry.body.blocks.every((block) => block.type === 'text') ) } From 221a168c06b90f461893b1ded5d679ed07740f43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:08:19 -0700 Subject: [PATCH 12/15] refactor(native-chat): what each refusal reason means in its own module, under the refusal notice's line limit --- .../agent-session-refusal-notice.test.ts | 2 +- src/shared/agent-session-refusal-notice.ts | 186 +----------------- .../agent-session-refusal-reason-words.ts | 186 ++++++++++++++++++ 3 files changed, 189 insertions(+), 185 deletions(-) create mode 100644 src/shared/agent-session-refusal-reason-words.ts diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index b60356965c0b..d2feb6f97052 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -7,11 +7,11 @@ import { agentSessionReadHistoryRefusalParts, agentSessionRefusalCauseParts, agentSessionRefusalNotice, - agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts } from './agent-session-refusal-notice' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticeSentence diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index fb776f3c4e4b..31c576dadd6c 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -10,22 +10,18 @@ // something beside the words retries it. // Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. -import type { AgentSessionFailureKind } from './agent-session-failure' import { agentSessionFailureSentence, type AgentSessionFailureWordsContext } from './agent-session-failure-words' -import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_HISTORY_UNREAD_CAUSES, AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' -import type { - AgentSessionWireRefusal, - AgentSessionWireRefusalCode -} from './agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import { agentSessionRefusalFailure, parseAgentSessionWriteFailure, @@ -56,184 +52,6 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } -/** What the person can do about a refusal with this reason. */ -export type AgentSessionRefusalAction = - /** Use the control that sent the write again. */ - | 'retry' - /** Wait for what the notice names; its step says what. */ - | 'wait' - /** Take the step the notice names first. */ - | 'actFirst' - /** Continue somewhere else: the current conversation, or a new chat. */ - | 'goElsewhere' - | 'checkChat' - | 'updateOrca' - /** What the write was for is over: the question it answered moved on. */ - | 'nothingLeft' - /** Orca's own state or fault; nothing the person does gets past it. */ - | 'hostFinding' - -export type AgentSessionRefusalReasonWords = - /** The code's own words are the honest ones for this reason. */ - | { words: 'code'; action: AgentSessionRefusalAction } - /** What stopped the write, and the step past it where the person has one to take. */ - | { - cause: AgentSessionWriteNoticeSentence - step?: AgentSessionWriteNoticeSentence - action: AgentSessionRefusalAction - } - /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ - | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } - -function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { - return { words: 'code', action } -} - -function causeWords( - cause: AgentSessionWriteNoticeSentence, - action: AgentSessionRefusalAction, - step?: AgentSessionWriteNoticeSentence -): AgentSessionRefusalReasonWords { - return step ? { cause, step, action } : { cause, action } -} - -const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') -const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') -// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. -const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') - -// Every reason of every code, so a reason the host adds does not compile until it has words. -// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep -// their code's words. -const REASON_WORDS = { - agent_session_operation_invalid: { - requestMalformed: codeWords('hostFinding'), - operationIdInvalid: codeWords('hostFinding'), - messageIdReused: codeWords('hostFinding'), - // Settled under that id, so the control's retry goes out under a new one. - operationRefusedEarlier: codeWords('retry'), - journalWriteFailed: causeWords('recordFailed', 'retry'), - conversationCleared: causeWords( - 'conversationCleared', - 'goElsewhere', - 'openCurrentConversation' - ), - // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. - clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), - // Only an older host sends this, for a /clear it never settled; only that host resolves it. - conversationCommandUnconfirmed: codeWords('hostFinding'), - conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), - // The chat's agent process is being replaced, which a start or restart does. - handoffInFlight: AGENT_STARTING, - turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), - promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), - backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), - // No chat surface sends a rewind; a replayed one says only that it did not happen. - rewindRefused: codeWords('hostFinding'), - rewindUnconfirmed: codeWords('hostFinding'), - promptGone: causeWords('questionChanged', 'nothingLeft'), - optionRejected: causeWords('optionRejected', 'retry'), - providerStarting: AGENT_STARTING, - goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), - providerRejected: causeWords('agentRefused', 'retry'), - providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, - notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, - providerMissing: { fact: 'providerMissing', action: 'actFirst' }, - historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, - managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, - accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, - managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, - attachFailed: codeWords('retry') - }, - agent_session_ownership_unknown: { - sessionNotAttached: codeWords('retry'), - noLiveOwner: codeWords('retry'), - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - recordMissing: codeWords('retry'), - replaySuperseded: codeWords('retry'), - leaseMoved: codeWords('retry'), - spawnIdentityMismatch: codeWords('hostFinding'), - notResumable: codeWords('retry'), - noProviderChild: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - // Trying again retries the stop that could not prove the exit. - previousExitUnverifiable: causeWords('ownerUnproven', 'retry') - }, - agent_session_conflict: { - chatStarting: AGENT_STARTING, - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - ownerAlive: codeWords('retry'), - identityMismatch: codeWords('hostFinding'), - sessionExists: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - tabIdTaken: codeWords('retry') - }, - execution_owner_reconciling: { - hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), - recordUnreadable: causeWords('recordUnreadable', 'hostFinding') - }, - agent_session_checkpoint_stale: { - fenceStale: codeWords('retry'), - leaseMoved: codeWords('retry'), - recordMissing: codeWords('retry') - }, - agent_session_identity_required: { - recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), - transcriptNotFound: codeWords('retry'), - transcriptUnreadable: codeWords('hostFinding') - }, - agent_session_operation_conflict: { - fingerprintMismatch: codeWords('hostFinding'), - operationIdReused: codeWords('hostFinding'), - handoffInFlight: codeWords('retry') - }, - agent_session_operation_expired: { operationExpired: codeWords('retry') }, - agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, - agent_session_operation_unknown: { - outcomeUnknown: codeWords('checkChat'), - resultLost: codeWords('checkChat'), - rewindUnconfirmed: codeWords('checkChat'), - tabUnconfirmed: codeWords('checkChat') - }, - agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, - agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, - agent_session_journal_unreadable: { - // No retry reads past damage, and the words name no step: it only can't load. - journalCorrupt: causeWords('historyUnusable', 'hostFinding'), - // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), - journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') - }, - // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, - // which only a method the host doesn't know proves; no reason here means an older Orca. An - // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing - // the capability words this with its own older copy. - structured_agent_session_unsupported: { - clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), - hostDisabled: causeWords('notAvailable', 'hostFinding'), - hostUnsupported: causeWords('notAvailable', 'hostFinding') - }, - agent_session_owner_restart_failed: {} -} satisfies { - [C in AgentSessionWireRefusalCode]: Record< - AgentSessionRefusalReason, - AgentSessionRefusalReasonWords - > -} - -/** The words and next step a reason gets; undefined when the refusal names none. */ -export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal -): AgentSessionRefusalReasonWords | undefined { - const reason = failure.details?.reason - const byReason: Partial> | undefined = - REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] -} - /** A cause, and that the request did not happen unless the cause already says so. */ function causeParts( cause: AgentSessionWriteNoticeSentence, diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts new file mode 100644 index 000000000000..9712cbf28d14 --- /dev/null +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -0,0 +1,186 @@ +// What each refusal reason a host names means for the person: the words that say what stopped the +// write, the step past it where they have one, and what they can do. The refusal notice reads it. + +import type { AgentSessionFailureKind } from './agent-session-failure' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import type { AgentSessionWriteRefusal } from './agent-session-write-failure' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' + +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // Only an older host sends this, for a /clear it never settled; only that host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + providerMissing: { fact: 'providerMissing', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + // Trying again retries the stop that could not prove the exit. + previousExitUnverifiable: causeWords('ownerUnproven', 'retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') + }, + // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, + // which only a method the host doesn't know proves; no reason here means an older Orca. An + // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing + // the capability words this with its own older copy. + structured_agent_session_unsupported: { + clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), + hostDisabled: causeWords('notAvailable', 'hostFinding'), + hostUnsupported: causeWords('notAvailable', 'hostFinding') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} From 16ca69f148a513f60d958c0eb87d2a903b38fb55 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:07:27 -0700 Subject: [PATCH 13/15] refactor(native-chat): keep only the drop of a message the host recorded in doubt #24710 now draws every message the host rejected where it was rejected, in the host's words, so the journal-drawn not-delivered row, its outbox exclusion and the Retry rotation record go. Tests state which rejected message has a Retry: only a failed start no agent took, on a host that retries in place. --- ...ed-agent-session-undelivered-rows.test.tsx | 120 ---------- .../use-mobile-structured-agent-session.ts | 4 +- ...on-delivery-notices.rejected-retry.test.ts | 87 ++++++++ ...ructured-agent-session-delivery-notices.ts | 4 +- ...ent-session-failed-start-elsewhere.test.ts | 143 +----------- ...ctured-agent-session-message-projection.ts | 6 +- ...gent-session-outbox-retry-rotation.test.ts | 207 ------------------ .../structured-agent-session-outbox-retry.ts | 7 +- ...ed-agent-session-delivery-notices.test.tsx | 38 ---- .../use-structured-agent-session-messages.ts | 9 +- .../use-structured-agent-session.ts | 3 +- .../agent-session-refusal-notice.test.ts | 2 +- ...ed-agent-session-failed-start-elsewhere.ts | 58 +---- ...ctured-agent-session-message-projection.ts | 42 +--- ...tructured-agent-session-outbox-delivery.ts | 4 +- ...tructured-agent-session-outbox-rotation.ts | 33 --- src/shared/structured-agent-session-outbox.ts | 11 +- 17 files changed, 124 insertions(+), 654 deletions(-) delete mode 100644 mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.rejected-retry.test.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts delete mode 100644 src/shared/structured-agent-session-outbox-rotation.ts diff --git a/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx deleted file mode 100644 index 7f5d96953ff0..000000000000 --- a/mobile/src/session/use-mobile-structured-agent-session-undelivered-rows.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import { afterEach, expect, it, vi } from 'vitest' -import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../src/shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' -import type { RpcClient } from '../transport/rpc-client' -import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { getItem: vi.fn(async () => null), setItem: vi.fn(), removeItem: vi.fn() } -})) - -const MESSAGE: AgentJournalRenderItem = { - itemId: agentJournalSubmissionKey('op-undelivered'), - revision: 0, - sequence: 1, - observedAt: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'never got it' }] } -} - -/** Handed over, then the agent's history showed it never got it. */ -const UNDELIVERED: AgentJournalSubmission = { - clientMessageId: 'op-undelivered', - fence: 3, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - submittedAt: 1, - resolvedAt: 2, - handoverRecorded: true, - handedOverAt: 1, - recovered: true, - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) -} - -function snapshot(): AgentSessionSubscribeEvent { - return { - type: 'snapshot', - sessionId: 'session-1', - fence: 3, - page: { - sessionId: 'session-1', - epoch: 'epoch-1', - fence: 3, - direction: 'tail', - items: [MESSAGE], - removedItemIds: [], - submissions: [UNDELIVERED], - window: { - oldest: { epoch: 'epoch-1', sequence: 1 }, - newest: { epoch: 'epoch-1', sequence: 1 }, - nextCursor: { epoch: 'epoch-1', sequence: 2 } - }, - liveCursor: { epoch: 'epoch-1', sequence: 1 }, - hasOlder: false, - hasNewer: false - } - } -} - -// Module scope: the harness assigns these, which a test body's own `let` would narrow to null. -let renderer: ReactTestRenderer | null = null -let hook: ReturnType | null = null -let listener: ((value: unknown) => void) | null = null -afterEach(() => { - act(() => renderer?.unmount()) - renderer = null - hook = null - listener = null -}) - -// The phone does not mark a message as unsent yet; drawn, it would look delivered. -it('keeps a message the agent never got hidden, as before', async () => { - const client: RpcClient = { - sendRequest: async () => ({ - id: 'request-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }), - subscribe: (_method, _params, onData) => { - listener = onData - return () => {} - }, - updateTerminalSubscriptionViewport: () => {}, - getState: () => 'connected', - getReconnectAttempt: () => 0, - getLastConnectedAt: () => null, - onStateChange: () => () => {}, - notifyForeground: () => {}, - close: () => {} - } - function Harness(): null { - hook = useMobileStructuredAgentSession({ - client, - sessionId: 'session-1', - sourceIdentity: 'host-a\0workspace-a', - enabled: true, - connected: true, - agent: 'claude', - hostSupport: null, - onSendError: vi.fn() - }) - return null - } - - await act(async () => { - renderer = create(createElement(Harness)) - }) - await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) - act(() => listener?.(snapshot())) - - expect(hook?.session.messages).toEqual([]) -}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index ce507c771d0a..4dce8506bc13 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -173,9 +173,7 @@ export function useMobileStructuredAgentSession(args: { () => projectStructuredAgentSessionMessages(transcriptItems, [], state.submissions, { rejectedInPlace: false, - showsFailedStartsSentElsewhere: hostSupport?.retryMessage === true, - // The phone does not mark a message as unsent yet, so these stay hidden rather than look sent. - showsUndeliveredSentElsewhere: false + showsFailedStartsSentElsewhere: hostSupport?.retryMessage === true }), [transcriptItems, state.submissions, hostSupport?.retryMessage] ) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.rejected-retry.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.rejected-retry.test.ts new file mode 100644 index 000000000000..34e8ec6df350 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.rejected-retry.test.ts @@ -0,0 +1,87 @@ +// Which message the host recorded and then rejected has a Retry: only one no agent ever took, on a +// host that can queue that same message again. Every other one is drawn where it was rejected, in +// the host's words, and sending it again is a new message. + +import { describe, expect, it, vi } from 'vitest' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import type { SubmissionRejectionFact } from '../../../../shared/agent-session-failure' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const ID = 'op-message' +const KEY = agentJournalSubmissionKey(ID) +const ITEM: AgentJournalRenderItem = { + itemId: KEY, + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } +} + +function rejected( + fact: SubmissionRejectionFact, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId: ID, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + submittedAt: 4, + resolvedAt: 7, + handoverRecorded: true, + ...agentSessionFailureWords(fact, { surface: 'rejection', agentName: 'Codex' }), + ...patch + } +} + +function noticeOn(submission: AgentJournalSubmission, retriesInPlace: boolean) { + const retry = vi.fn() + const notice = structuredAgentSessionDeliveryNotices( + [], + 'Codex', + retry, + [submission], + [], + new Set(), + [], + new Set(), + [], + retriesInPlace + ).get(KEY) + return { notice, retry } +} + +describe('a message the host recorded and then rejected', () => { + it("is drawn in place with the host's words and no Retry, even on a host that retries in place", () => { + // Handed to the agent, which never got it: the agent may have seen it, so no Retry. + const undelivered = rejected({ kind: 'notDelivered' }, { handedOverAt: 5 }) + + expect(projectStructuredAgentSessionMessages([ITEM], [], [undelivered], [])).toEqual([ + expect.objectContaining({ id: KEY, unsent: true }) + ]) + const { notice } = noticeOn(undelivered, true) + expect(notice).toEqual({ text: 'This message was not delivered. Send it again to continue.' }) + }) +}) + +describe('a failed start no agent took', () => { + const failedStart = rejected({ kind: 'providerStartFailed' }) + + it('gets Retry in place when the host advertises retry-message', () => { + const { notice, retry } = noticeOn(failedStart, true) + expect(notice?.text).toBe('Codex stopped before it finished starting.') + notice?.onRetry?.() + expect(retry).toHaveBeenCalledExactlyOnceWith(ID) + }) + + it('gets no Retry on a host that does not advertise retry-message', () => { + expect(noticeOn(failedStart, false).notice).not.toHaveProperty('onRetry') + }) +}) 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 24b32151b1a5..10be227fd565 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 @@ -54,7 +54,6 @@ import { structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition' import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' -import { structuredAgentSessionReplacedIds } from '../../../../shared/structured-agent-session-outbox-rotation' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' @@ -280,8 +279,7 @@ export function structuredAgentSessionDeliveryNotices( const shown = structuredAgentSessionRejectedShownInPlace( submissions, queuedMessageIds, - commandItemIds, - structuredAgentSessionReplacedIds(outbox) + commandItemIds ) for (const submission of rejected.values()) { const { clientMessageId } = submission diff --git a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts index eba90ca76041..5a44b5404f07 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-failed-start-elsewhere.test.ts @@ -3,10 +3,7 @@ // on the desktop as any rejection drawn in place, on the phone by this rule alone. import { describe, expect, it, vi } from 'vitest' -import { - agentSessionFailureSentence, - agentSessionFailureWords -} from '../../../../shared/agent-session-failure-words' +import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalMessageItem, @@ -215,17 +212,9 @@ describe('a message sent from elsewhere whose start failed for good', () => { } }) - // Only an older host's rows: a current one never keeps a failed start's hand-over. Its Retry can't - // queue a message an agent already took; drawn with its own words and no Retry. - it("is drawn with no Retry once an agent already took it, in an older host's rows", () => { + it('is never drawn for one an agent already took', () => { const handedOver = { ...rejected({ kind: 'providerStartFailed' }), handedOverAt: 5 } - expect(projectStructuredAgentSessionMessages([TEXT], [], [handedOver], DESKTOP)).toEqual([ - expect.objectContaining({ id: KEY, unsent: true }) - ]) - // Not one the host can queue again, so no Retry even where it queues others. - expect(notices([handedOver], vi.fn()).get(KEY)).toEqual({ - text: 'Codex stopped before it finished starting. Send your message to try again.' - }) + expect(projectStructuredAgentSessionMessages([TEXT], [], [handedOver], PHONE)).toEqual([]) }) }) @@ -271,129 +260,3 @@ describe('a message whose start failed for good, queued again by its Retry', () ]) }) }) - -// Rejected after it was handed over, or shown by the agent's history never to have reached it. No -// outbox entry shows it — this client let go once the host recorded it in doubt, or another device -// sent it — so the journal does, with its own words and no Retry. -describe('a message rejected after it was handed over', () => { - const undelivered = (): AgentJournalSubmission => ({ - ...rejected({ kind: 'notDelivered' }), - handedOverAt: 5, - recovered: true - }) - - function noticeOf(submissions: AgentJournalSubmission[]) { - return structuredAgentSessionDeliveryNotices( - [], - 'Claude', - vi.fn(), - submissions, - [], - new Set(), - [], - new Set(), - [], - true - ).get(KEY) - } - - it('shows as unsent with no Retry, on any desktop host', () => { - const submissions = [undelivered()] - for (const options of [ - DESKTOP, - { rejectedInPlace: false, showsUndeliveredSentElsewhere: true } - ]) { - expect(projectStructuredAgentSessionMessages([TEXT], [], submissions, options)).toEqual([ - expect.objectContaining({ id: KEY, unsent: true }) - ]) - } - // The host cannot queue a message it handed over again, so the person sends it anew. - expect(noticeOf(submissions)).toEqual({ - text: 'This message was not delivered. Send it again to continue.' - }) - }) - - // The phone does not mark a message as unsent yet: drawn there, it would look delivered. - it('stays hidden on a surface that cannot mark it unsent', () => { - expect( - projectStructuredAgentSessionMessages([TEXT], [], [undelivered()], { - rejectedInPlace: false, - showsUndeliveredSentElsewhere: false - }) - ).toEqual([]) - }) - - // A doubt the host settled later, after this client let its entry go: the agent refused it, or a - // Stop withdrew it. - it('says why when the agent refused it after its doubt, and stays hidden when withdrawn', () => { - const dispatching = { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: ID, - sessionId: 'session-1', - text: 'Continue where you left off', - attachments: [], - queuedAt: 4 - }), - state: 'dispatching' as const - } - const inDoubt: AgentJournalSubmission = { - ...undelivered(), - dispatchState: 'unknown', - reason: 'provider_write_outcome_unknown: EPIPE', - rejection: undefined - } - expect(reconcileStructuredAgentSessionOutbox([dispatching], [inDoubt], [TEXT])).toEqual([]) - - const refused = { ...undelivered(), ...rejected({ kind: 'providerRejected' }), handedOverAt: 5 } - expect(projectStructuredAgentSessionMessages([TEXT], [], [refused], DESKTOP)).toEqual([ - expect.objectContaining({ id: KEY, unsent: true }) - ]) - expect(noticeOf([refused])?.text).toBe( - agentSessionFailureSentence({ kind: 'providerRejected' }, 'rejection', { - agentName: 'Claude', - retryControl: false - }) - ) - expect(noticeOf([refused])?.onRetry).toBeUndefined() - - const withdrawn = { ...undelivered(), ...rejected({ kind: 'cancelled' }), handedOverAt: 5 } - expect(projectStructuredAgentSessionMessages([TEXT], [], [withdrawn], DESKTOP)).toEqual([]) - expect(noticeOf([withdrawn])).toBeUndefined() - }) - it('is drawn once by the entry of the client that still holds it', () => { - const own = reconcileStructuredAgentSessionOutbox( - [ - { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: ID, - sessionId: 'session-1', - text: 'Continue where you left off', - attachments: [], - queuedAt: 4 - }), - state: 'dispatching' - } - ], - [undelivered()], - [] - ) - expect( - projectStructuredAgentSessionMessages([TEXT], own, [undelivered()], DESKTOP).map( - ({ id }) => id - ) - ).toEqual([KEY]) - }) - - it('stays hidden once the same words went through since', () => { - const copy: AgentJournalSubmission = { - ...undelivered(), - clientMessageId: 'copy', - dispatchState: 'accepted', - submittedAt: 9, - resolvedAt: 9 - } - expect( - projectStructuredAgentSessionMessages([TEXT], [], [undelivered(), copy], DESKTOP) - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts index 20fddc9bdd2d..f527442c2b15 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -13,15 +13,13 @@ export function projectStructuredAgentSessionMessages( outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], /** The queue's live cards; required, since a rejected message a card holds must not draw twice. */ - queuedMessageIds: readonly string[], - /** The whole outbox, where `outbox` leaves out the sends drawn as cards. */ - sentHere: readonly StructuredAgentSessionOutboxEntry[] = outbox + queuedMessageIds: readonly string[] ) { return projectMessages( items, outbox, submissions, - { rejectedInPlace: true, queuedMessageIds, sentHere }, + { rejectedInPlace: true, queuedMessageIds }, projectStructuredQuestionMessages ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts deleted file mode 100644 index 4bd1bf2d3405..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-retry-rotation.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// @vitest-environment happy-dom - -// A Retry on this client's own message the agent never got sends it again under a new id, at once, -// even while the agent works with queued follow-ups on. The new copy is the message: the old row is -// never drawn beside it, whatever becomes of the copy, and a reopen reads the same from the saved -// outbox. Drawn as the chat draws it: the transcript leaves out sends shown as queued cards. - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' -import type { - AgentJournalRenderItem, - AgentJournalSubmission -} from '../../../../shared/agent-session-journal-types' -import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' -import { - createStructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxEntry -} from '../../../../shared/structured-agent-session-outbox' -import { reconcileStructuredAgentSessionOutbox } from '../../../../shared/structured-agent-session-outbox-reconcile' -import { structuredAgentSessionEntryAttempt } from '../../../../shared/structured-agent-session-outbox-delivery' -import { disposeStructuredAgentSessionSendResult } from '../../../../shared/structured-agent-session-send-disposition' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' -import { retryStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-retry' -import { - getStructuredAgentSessionOutbox, - readOutbox, - writeOutbox -} from './structured-agent-session-outbox-storage' - -const SESSION = 'session-1' -// The agent is working and the host queues follow-ups, as the default setting has it. -const QUEUEING = { capability: 'supported', enabled: true } as const -const ORIGINAL = 'op-original' -const COPY = 'op-copy' - -const ITEM: AgentJournalRenderItem = { - itemId: agentJournalSubmissionKey(ORIGINAL), - revision: 0, - sequence: 5, - observedAt: 5, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } -} - -const UNDELIVERED: AgentJournalSubmission = { - clientMessageId: ORIGINAL, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - submittedAt: 4, - resolvedAt: 7, - handoverRecorded: true, - handedOverAt: 5, - recovered: true, - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) -} - -function copyRow(patch: Partial): AgentJournalSubmission { - return { - ...UNDELIVERED, - clientMessageId: COPY, - dispatchState: 'pending', - reason: null, - rejection: undefined, - submittedAt: 9, - resolvedAt: null, - handedOverAt: undefined, - recovered: undefined, - ...patch - } -} - -/** What the chat draws: each row's id, and the ids that say they did not go through (a quiet - * "Sending…" is not one). */ -function drawn( - outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: AgentJournalSubmission[] -) { - return { - rows: projectStructuredAgentSessionMessages( - [ITEM], - outboxOutsideQueuedCards(outbox, [], true, QUEUEING), - submissions, - { rejectedInPlace: true, sentHere: outbox } - ).map(({ id }) => id), - noticed: [ - ...structuredAgentSessionDeliveryNotices( - outbox, - 'Claude', - vi.fn(), - submissions, - [], - new Set() - ) - ] - .filter(([, notice]) => !notice.sending) - .map(([id]) => id) - } -} - -describe("a Retry on this client's own message the agent never got", () => { - let afterRetry: StructuredAgentSessionOutboxEntry[] - - beforeEach(() => { - localStorage.clear() - const own = reconcileStructuredAgentSessionOutbox( - [ - { - ...createStructuredAgentSessionOutboxEntry({ - clientMessageId: ORIGINAL, - sessionId: SESSION, - text: 'hello', - attachments: [], - queuedAt: 4 - }), - state: 'dispatching', - lastAttemptAt: 4 - } - ], - [UNDELIVERED], - [] - ) - writeOutbox(SESSION, own) - retryStructuredAgentSessionOutboxEntry({ - clientMessageId: ORIGINAL, - sessionId: SESSION, - submissions: [UNDELIVERED], - setError: vi.fn(), - createOperationId: () => COPY - }) - afterRetry = getStructuredAgentSessionOutbox(SESSION) - }) - - it('draws only the copy while it waits to go out, and after a reopen', () => { - expect(afterRetry).toMatchObject([{ clientMessageId: COPY, rotatedFrom: [ORIGINAL] }]) - const copyKey = agentJournalSubmissionKey(COPY) - expect(drawn(afterRetry, [UNDELIVERED])).toEqual({ rows: [copyKey], noticed: [] }) - // The saved outbox remembers the id it replaced. - expect(drawn(readOutbox(SESSION), [UNDELIVERED])).toEqual({ rows: [copyKey], noticed: [] }) - }) - - /** The drain's attempt at the copy while the agent works, and the host's answer to what it sent: - * a queued card for a send that asks to be queued, else the copy steered into the turn. */ - function sendWhileWorking(patch: Partial) { - const { stored, wire } = structuredAgentSessionEntryAttempt(afterRetry[0]!, QUEUEING) - const inFlight = [{ ...stored, state: 'dispatching' as const, lastAttemptAt: 9 }] - const row = copyRow(patch) - const { entries } = disposeStructuredAgentSessionSendResult({ - entries: inFlight, - entry: inFlight[0]!, - result: { - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 9 }, - value: - wire.sentDelivery === 'queue-if-active' - ? { clientMessageId: COPY, queued: { messageId: COPY, position: 1, state: 'waiting' } } - : { clientMessageId: COPY, submission: row } - }, - createOperationId: () => 'unused' - }) - return { entries, submissions: [UNDELIVERED, row] } - } - - it('sends the copy at once while the agent works, so it is never a card beside the old row', () => { - expect(structuredAgentSessionEntryAttempt(afterRetry[0]!, QUEUEING).wire.sentDelivery).toBe( - null - ) - const { entries, submissions } = sendWhileWorking({}) - expect(drawn(entries, submissions)).toEqual({ - rows: [agentJournalSubmissionKey(COPY)], - noticed: [] - }) - }) - - it('never draws the old row beside the copy once the copy is handed over', () => { - const { entries, submissions } = sendWhileWorking({ handedOverAt: 10 }) - expect(drawn(entries, submissions).noticed).toEqual([]) - expect(drawn(entries, submissions).rows).not.toContain(agentJournalSubmissionKey(ORIGINAL)) - }) - - // The transcript leaves out a send it draws as a card; the whole outbox still names the row. - it('hides the old row while the transcript leaves its copy out', () => { - expect( - projectStructuredAgentSessionMessages([ITEM], [], [UNDELIVERED], { - rejectedInPlace: true, - sentHere: afterRetry - }) - ).toEqual([]) - }) - - it('never draws the old row beside a copy the host refused', () => { - const refused = afterRetry.map((entry) => ({ - ...entry, - lastAttemptAt: 9, - lastFailure: { kind: 'refused' as const, code: 'agent_session_journal_unreadable' as const } - })) - expect(drawn(refused, [UNDELIVERED])).toEqual({ - rows: [agentJournalSubmissionKey(COPY)], - noticed: [agentJournalSubmissionKey(COPY)] - }) - }) -}) 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 ea0c58378ca8..d431cccb0363 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,6 @@ // id when the recorded one can only ever replay a settled rejection. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { rotateStructuredAgentSessionOutboxEntryId } from '../../../../shared/structured-agent-session-outbox-rotation' import { structuredAgentSessionEntryIdExpired, structuredAgentSessionEntryRejectedByHost, @@ -42,10 +41,8 @@ export function retryStructuredAgentSessionOutboxEntry(args: { const rotated = outbox.map((entry) => entry.clientMessageId === clientMessageId ? { - ...rotateStructuredAgentSessionOutboxEntryId( - retriedByUser(entry), - args.createOperationId() - ), + ...retriedByUser(entry), + clientMessageId: args.createOperationId(), state: 'queued' as const, lastAttemptAt: null, retryAfterUnknownSubmittedAt: null diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx index 9ab38f6e0289..08232625feaa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx @@ -2,9 +2,6 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, expect, it } from 'vitest' -import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' -import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' @@ -125,38 +122,3 @@ it('keeps the notice of a row that did not change when another one does', () => expect(result.current.size).toBe(2) expect(result.current.get('orca:lost')).toBe(lost) }) - -// A message the agent's history showed it never got is drawn from its row on a client with no -// entry for it, and says so. -it('says a message the agent never got was not delivered, with no Retry, on a client with no entry', () => { - const undelivered: AgentJournalSubmission = { - clientMessageId: 'op-undelivered', - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - submittedAt: 4, - resolvedAt: 7, - handoverRecorded: true, - handedOverAt: 5, - recovered: true, - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) - } - const { result } = renderHook(() => - useStructuredAgentSessionDeliveryNotices({ - outbox: EMPTY, - submissions: [undelivered], - journalItems: EMPTY, - failedHere: NONE, - queuedMessageIds: NO_CARDS, - retry: () => {}, - retriesInPlace: true, - retryWaitsForHost: NONE, - agentName: 'Claude' - }) - ) - - expect(result.current.get(agentJournalSubmissionKey('op-undelivered'))).toEqual({ - text: 'This message was not delivered. Send it again to continue.' - }) -}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts index f380c85f57cb..af38cda41df1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.ts @@ -10,13 +10,10 @@ export function useStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[], - /** The whole outbox, where `outbox` leaves out the sends drawn as cards. */ - sentHere: readonly StructuredAgentSessionOutboxEntry[] = outbox + queuedMessageIds: readonly string[] ) { return useMemo( - () => - projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds, sentHere), - [items, outbox, submissions, queuedMessageIds, sentHere] + () => projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds), + [items, outbox, submissions, queuedMessageIds] ) } 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 a7195caa659e..240ef87ec3cb 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 @@ -189,8 +189,7 @@ export function useStructuredAgentSession(args: { transcriptItems, transcriptOutbox, submissions, - queuedMessageIds, - outbox + queuedMessageIds ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index d2feb6f97052..b60356965c0b 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -7,11 +7,11 @@ import { agentSessionReadHistoryRefusalParts, agentSessionRefusalCauseParts, agentSessionRefusalNotice, + agentSessionRefusalReasonWords, agentSessionWriteFailureNotice, agentSessionWriteNoticeEnglish, agentSessionWriteNoticeParts } from './agent-session-refusal-notice' -import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' import { AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticeSentence diff --git a/src/shared/structured-agent-session-failed-start-elsewhere.ts b/src/shared/structured-agent-session-failed-start-elsewhere.ts index 8302f637264b..e08978eba940 100644 --- a/src/shared/structured-agent-session-failed-start-elsewhere.ts +++ b/src/shared/structured-agent-session-failed-start-elsewhere.ts @@ -1,67 +1,29 @@ -// A message no outbox entry here shows — one the host sent itself, such as a restart's -// continuation, one another device sent, or one this client let go once the host recorded it — -// that never reached its agent. The chat shows it from the journal as an unsent message. -// One whose start failed for good, on a host that can queue it again, says why, and its Retry -// queues that same message again. One rejected after it was handed over (or that the agent's own -// history showed it never got) says why, with no Retry: the host re-arms only a message no agent -// took, and a Retry that sent a copy from every device showing it could send it twice. +// A message this client did not send — one the host sent itself, such as a restart's continuation, +// or one another device sent — whose start failed for good. No outbox entry here shows it, so the +// chat shows it from the journal, on a host that can queue it again: an unsent message that says +// why, whose Retry queues that same message again. import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' -import { - classifyDispatchRejection, - dispatchWasWithdrawn, - failedBeforeHandover -} from './structured-agent-session-dispatch-rejection' - -type SentHere = readonly Pick< - StructuredAgentSessionOutboxEntry, - 'clientMessageId' | 'rotatedFrom' ->[] +import { failedBeforeHandover } from './structured-agent-session-dispatch-rejection' export function failedStartsSentElsewhere( submissions: readonly AgentJournalSubmission[], - outbox: SentHere -): AgentJournalSubmission[] { - return unsentElsewhere(submissions, outbox, failedBeforeHandover) -} - -export function undeliveredSentElsewhere( - submissions: readonly AgentJournalSubmission[], - outbox: SentHere -): AgentJournalSubmission[] { - return unsentElsewhere( - submissions, - outbox, - (submission) => - submission.dispatchState === 'rejected' && - !dispatchWasWithdrawn(submission) && - (submission.handedOverAt !== undefined || - classifyDispatchRejection(submission).kind === 'notDelivered') - ) -} - -function unsentElsewhere( - submissions: readonly AgentJournalSubmission[], - outbox: SentHere, - unsent: (submission: AgentJournalSubmission) => boolean + outbox: readonly Pick[] ): AgentJournalSubmission[] { - // A message sent again here under a new id is still this client's: its old rows are not drawn. - const sentHere = new Set( - outbox.flatMap((entry) => [entry.clientMessageId, ...(entry.rotatedFrom ?? [])]) - ) + const sentHere = new Set(outbox.map((entry) => entry.clientMessageId)) return submissions.filter( (submission) => !sentHere.has(submission.clientMessageId) && // A queued card's message: the card shows it, and its own Retry is the card's. submission.queuedMessageId === undefined && - unsent(submission) && + failedBeforeHandover(submission) && !sentAgainSince(submission, submissions) ) } -/** The same words went through since, as a message of their own: a Retry that sent a new copy - * under a new id. A copy's body is the original's, so its body-only fingerprint matches. */ +/** The same words went through since, as a message of their own: an older host's Retry sent a new + * copy under a new id. A copy's body is the original's, so its body-only fingerprint matches. */ function sentAgainSince( original: AgentJournalSubmission, submissions: readonly AgentJournalSubmission[] diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index e4bae3a3d065..ef7344444128 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -8,12 +8,8 @@ import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-reject 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 { - failedStartsSentElsewhere, - undeliveredSentElsewhere -} from './structured-agent-session-failed-start-elsewhere' +import { failedStartsSentElsewhere } from './structured-agent-session-failed-start-elsewhere' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' -import { structuredAgentSessionReplacedIds } from './structured-agent-session-outbox-rotation' export type StructuredAgentSessionMessageProjectionOptions = { /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. @@ -24,12 +20,6 @@ export type StructuredAgentSessionMessageProjectionOptions = { /** With `rejectedInPlace` off, still draw a failed start sent from elsewhere as not sent, for a * host that can queue it again: no composer here holds it. */ showsFailedStartsSentElsewhere?: boolean - /** With `rejectedInPlace` off, still draw one sent from elsewhere that was rejected after it was - * handed over, as not sent, on a surface that marks a message so. */ - showsUndeliveredSentElsewhere?: boolean - /** This client's whole outbox, cards' sends included, where `outbox` is only what the transcript - * draws: no row of a message it holds, or of an id its Retry replaced, is drawn beside it. */ - sentHere?: readonly Pick[] } /** The loaded items that are a conversation command such as `/compact`, by item id. */ @@ -52,10 +42,7 @@ export function structuredAgentSessionCommandItemIds( export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], queuedMessageIds: readonly string[], - commandItemIds: ReadonlySet, - /** Ids a Retry here replaced with a new one (`structuredAgentSessionReplacedIds`): the copy is - * the message, so the old row is never drawn beside it. */ - replacedIds: ReadonlySet = new Set() + commandItemIds: ReadonlySet ): Set { const cards = new Set(queuedMessageIds) // Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it @@ -80,7 +67,6 @@ export function structuredAgentSessionRejectedShownInPlace( dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined || cards.has(submission.clientMessageId) || - replacedIds.has(submission.clientMessageId) || commandItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) || // Collapses resends of a rejected message: past Retries resent it under a new id, and the // host re-delivers its own messages under new ids. Only a later copy sent once the rejection @@ -111,29 +97,21 @@ export function projectStructuredAgentSessionMessages( .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) - const sentHere = options.sentHere ?? outbox const inPlace = options.rejectedInPlace ? structuredAgentSessionRejectedShownInPlace( submissions, options.queuedMessageIds ?? [], - structuredAgentSessionCommandItemIds(items), - structuredAgentSessionReplacedIds(sentHere) + structuredAgentSessionCommandItemIds(items) ) : new Set() - // Sent from elsewhere and refused for good by a failed start, or rejected after it was handed - // over: shown as unsent where the journal put it, by a client that draws no other rejection in - // place. + // Sent from elsewhere and refused for good by a failed start: shown as unsent where the journal + // recorded it, by a client that draws no other rejection in place. const unsentElsewhere = new Set( - options.rejectedInPlace - ? [] - : [ - ...(options.showsFailedStartsSentElsewhere - ? failedStartsSentElsewhere(submissions, sentHere) - : []), - ...(options.showsUndeliveredSentElsewhere - ? undeliveredSentElsewhere(submissions, sentHere) - : []) - ].map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) + !options.rejectedInPlace && options.showsFailedStartsSentElsewhere + ? failedStartsSentElsewhere(submissions, outbox).map((submission) => + agentJournalSubmissionKey(submission.clientMessageId) + ) + : [] ) const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] diff --git a/src/shared/structured-agent-session-outbox-delivery.ts b/src/shared/structured-agent-session-outbox-delivery.ts index 604b8ff307d4..28ced3d2fee7 100644 --- a/src/shared/structured-agent-session-outbox-delivery.ts +++ b/src/shared/structured-agent-session-outbox-delivery.ts @@ -20,8 +20,7 @@ export type StructuredAgentSessionQueueDelivery = { /** Whether this entry's next request asks to be queued. An attempted id asks exactly what it sent, * except of a host known not to queue, which rejects the field before its operation ledger, so * nothing there was recorded with it. A first attempt asks only of a host known to queue, with - * the setting on, for plain text that is not a launch prompt nor a Retry's copy under a new id: - * a Retry goes now, as one in place does, never into a card beside the message it replaces. */ + * the setting on, for plain text that is not a launch prompt. */ export function structuredAgentSessionEntryAsksToQueue( entry: StructuredAgentSessionOutboxEntry, host: StructuredAgentSessionQueueDelivery @@ -33,7 +32,6 @@ export function structuredAgentSessionEntryAsksToQueue( host.capability === 'supported' && host.enabled && entry.source !== 'launch' && - entry.rotatedFrom === undefined && entry.body.blocks.every((block) => block.type === 'text') ) } diff --git a/src/shared/structured-agent-session-outbox-rotation.ts b/src/shared/structured-agent-session-outbox-rotation.ts deleted file mode 100644 index 450c4fb115e4..000000000000 --- a/src/shared/structured-agent-session-outbox-rotation.ts +++ /dev/null @@ -1,33 +0,0 @@ -// A message the outbox sends again under a new id keeps the ids it replaced: the journal's rows -// under them are this message's, so the chat never draws them beside it. - -import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' - -/** The message under a new id, remembering the one it replaces. */ -export function rotateStructuredAgentSessionOutboxEntryId( - entry: StructuredAgentSessionOutboxEntry, - clientMessageId: string -): StructuredAgentSessionOutboxEntry { - return { - ...entry, - clientMessageId, - rotatedFrom: [...(entry.rotatedFrom ?? []), entry.clientMessageId] - } -} - -/** Every id this client's messages went out under before a Retry replaced it. */ -export function structuredAgentSessionReplacedIds( - outbox: readonly Pick[] -): ReadonlySet { - return new Set(outbox.flatMap((entry) => entry.rotatedFrom ?? [])) -} - -/** The replaced ids a saved entry carries; a malformed list is dropped. */ -export function parseStructuredAgentSessionOutboxRotation(entry: { - rotatedFrom?: unknown -}): Pick { - const { rotatedFrom } = entry - return Array.isArray(rotatedFrom) && rotatedFrom.every((id) => typeof id === 'string') - ? { rotatedFrom } - : {} -} diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 005ab0fcd870..13b0684c9304 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -15,10 +15,6 @@ import { type StructuredAgentSessionSendMutation } from './structured-agent-session-send-mutation' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' -import { - parseStructuredAgentSessionOutboxRotation, - rotateStructuredAgentSessionOutboxEntryId -} from './structured-agent-session-outbox-rotation' /** `rejected`: settled as not delivered. The drain never sends it again and nothing queues behind * it. One the host refused unrecorded waits for the user's Retry. One it recorded owes no delivery @@ -51,9 +47,6 @@ export type StructuredAgentSessionOutboxEntry = { * 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 - /** The ids this message went out under before a new one replaced them, oldest first - * (structured-agent-session-outbox-rotation). */ - rotatedFrom?: string[] } /** A host's rejection fact as a message keeps it: never its provider detail, whose log text is not @@ -216,7 +209,8 @@ export function requeueStructuredAgentSessionSendRefusal( // doubt, may have landed, so those keep it. Only a settled refusal proves the message never // landed. return { - ...rotateStructuredAgentSessionOutboxEntryId(entry, createOperationId()), + ...entry, + clientMessageId: createOperationId(), state: refusalSettled ? 'rejected' : 'queued', lastAttemptAt: null, retryAfterUnknownSubmittedAt: null @@ -261,7 +255,6 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), - ...parseStructuredAgentSessionOutboxRotation(entry), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From 830d18e28b2b2babaf12a14fe764f6cff86414fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:00:43 -0700 Subject: [PATCH 14/15] test(native-chat): a Stop's send recorded in doubt leaves the outbox, and the next send goes out --- ...uctured-agent-session-outbox-withdrawn-send.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts index 214ed97872aa..f8e1d0102efa 100644 --- a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -1,5 +1,6 @@ // A send the host withdrew (a Stop took it back before it ran) leaves the outbox with no notice, so -// the next queued send goes out. One the host left in doubt holds the queue until its Retry. +// the next queued send goes out. One the host recorded in doubt leaves it too: its row draws it, and +// the host never sends it again. import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from './agent-session-failure' @@ -61,7 +62,7 @@ describe('the send a Stop ended before its turn opened', () => { }) }) - it('holds the queue when left in doubt', () => { + it('leaves the outbox when recorded in doubt, and the next send goes out', () => { const inDoubt = submission({ dispatchState: 'unknown', recovered: true, @@ -70,9 +71,10 @@ describe('the send a Stop ended before its turn opened', () => { const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt], []) + expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ - state: 'blocked', - entry: { clientMessageId: 'stopped' } + state: 'dispatch', + entry: { clientMessageId: 'next' } }) }) }) From c5ee559bdafb7347f64ab79dc4319d0b7339e64d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:21:04 -0700 Subject: [PATCH 15/15] test(native-chat): read a Stop's unproven close off the child, as the exit lifecycle now keeps it --- .../structured-agent-session-codex-stop-row.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 2a7a0dd7b49e..eb00daafd545 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -677,7 +677,8 @@ describe('a follow-up Codex never echoed, when a Stop whose interrupt failed', ( await queueStaysOpen(await nextMessageGoesOut(followUpId, 0)) }) - // The owed stop is proven by the next send, which settles the follow-up in doubt only then. + // The stop's unproven close is joined, and proven, by the next send, which settles the follow-up + // in doubt only then. it.each(['internal error', 'unanswered'] as const)( 'stays in doubt once a Stop that could not prove the exit (%s) is proven, and the queue never stops', async (failure) => { @@ -689,11 +690,11 @@ describe('a follow-up Codex never echoed, when a Stop whose interrupt failed', ( await stop() await host.flushStreamedEvents(SESSION) - expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeDefined() + expect(host['sessions'].get(SESSION)?.child?.close).toBeDefined() expect(await followUpRow(followUpId)).toMatchObject({ dispatchState: 'pending' }) const outbox = await nextMessageGoesOut(followUpId, 1) - expect(host['sessions'].get(SESSION)?.owesProviderChildWindDown).toBeUndefined() + expect(host['sessions'].get(SESSION)?.child?.close).toBeUndefined() expect(await followUpRow(followUpId)).toMatchObject({ dispatchState: 'unknown', recovered: true