From 0b9642da855eb3790d47485e2004ad1d4452138b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:25:01 -0700 Subject: [PATCH 01/19] fix(native-chat): keep a message the host accepted then rejected in the desktop chat as not sent Draw it in place from the host's history, so a crash that loses the outbox no longer makes it vanish. A later copy of the same body supersedes it; the outbox row wins while it holds the message; the phone is unchanged. --- .../use-mobile-structured-agent-session.ts | 6 +- .../codex-collab-call-delegation.test.ts | 4 +- ...ab-call-rows-on-positional-clients.test.ts | 4 +- .../codex-structured-question-order.test.ts | 7 +- .../journal-turn-scope.test.ts | 4 +- ...structured-agent-session-host-test-data.ts | 4 +- ...structured-agent-session-mutation-plans.ts | 17 +- ...hatMessageList.stream-render.perf.test.tsx | 4 +- ...veChatMessageList.turn-membership.test.tsx | 4 +- ...iveChatMessageList.unsent-message.test.tsx | 90 +++++ ...tiveChatStructuredSession.test-harness.tsx | 4 +- .../NativeChatStructuredSession.tsx | 4 +- ...tiveChatStructuredSessionDelivery.test.tsx | 4 +- .../native-chat-rail-outline-parity.test.ts | 7 +- ...ructured-agent-session-delivery-notices.ts | 37 +- ...ssage-projection.rejected-in-place.test.ts | 317 ++++++++++++++++++ ...d-agent-session-message-projection.test.ts | 9 - ...ctured-agent-session-message-projection.ts | 10 +- ...ent-session-outbox.rejected-reply.test.tsx | 97 ++++++ .../agent-session-conversation-outline.ts | 3 +- .../native-chat-provider-retry-runs.test.ts | 8 +- src/shared/native-chat-types.ts | 4 +- ...ctured-agent-session-message-projection.ts | 73 +++- .../structured-agent-session-mutation.ts | 14 + 24 files changed, 685 insertions(+), 50 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 653d7fbaaa1a..ae688ab90723 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -158,7 +158,11 @@ export function useMobileStructuredAgentSession(args: { }) const messages = useMemo( - () => projectStructuredAgentSessionMessages(state.items, [], state.submissions), + // Off: the phone hands a rejected message back to its composer, so a row would show it twice. + () => + projectStructuredAgentSessionMessages(state.items, [], state.submissions, { + rejectedInPlace: false + }), [state.items, state.submissions] ) const turnId = activeStructuredAgentSessionTurnId(state.items) diff --git a/src/main/codex/codex-collab-call-delegation.test.ts b/src/main/codex/codex-collab-call-delegation.test.ts index 96131248ddc6..dbc116802e41 100644 --- a/src/main/codex/codex-collab-call-delegation.test.ts +++ b/src/main/codex/codex-collab-call-delegation.test.ts @@ -28,7 +28,9 @@ import { THREAD_ID } from './codex-structured-session-adapter-fixture' /** The delegation the parent's newest tool run reads as, the row a running chat's frontier judges. */ async function newestRunDelegation(frames: Frame[]): Promise { const { conversation } = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(await publishedRows(frames), [], []) + projectStructuredAgentSessionMessages(await publishedRows(frames), [], [], { + rejectedInPlace: true + }) ) const runs = conversation.filter((message: NativeChatMessage) => message.blocks.some((block) => block.type === 'tool-call') diff --git a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts index 795716ed553c..ae7770b5bebe 100644 --- a/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts +++ b/src/main/codex/codex-collab-call-rows-on-positional-clients.test.ts @@ -47,7 +47,9 @@ function positionalRuns(rows: AgentJournalRenderItem[]): { }) }) const transcript = projectNativeChatTranscript( - projectStructuredAgentSessionMessages(rows, [], []).map(withoutCallIds) + projectStructuredAgentSessionMessages(rows, [], [], { rejectedInPlace: true }).map( + withoutCallIds + ) ) // The conversation's runs, then each helper's section's. const runs = [ diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 8ad4b75bd38e..1a856b9ead46 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -206,6 +206,7 @@ function drawnPromptRows(): string[][] { client.items, [], client.submissions, + { rejectedInPlace: true }, projectStructuredQuestionMessages ) ) @@ -243,9 +244,9 @@ describe('a Codex ask with several questions', () => { ]) // Mobile draws the shared projection in journal order, one row per question. expect( - projectStructuredAgentSessionMessages(client.items, [], client.submissions).map( - ({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null) - ) + projectStructuredAgentSessionMessages(client.items, [], client.submissions, { + rejectedInPlace: true + }).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)) ).toEqual(ASKED.map(({ question }) => question)) }) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts index 2b8e4b80a761..be7a3d7d70f5 100644 --- a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -163,7 +163,9 @@ describe('stated turn scope', () => { const expected = [agentJournalItemKey(row('result')), agentJournalSubmissionKey('held')] const onPhone = () => { const { items, submissions } = renderJournalState(state) - return projectStructuredAgentSessionMessages(items, [], submissions) + return projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true + }) } // Still waiting: drawn after everything the agent did, the command's result included. expect(drawn(onPhone())).toEqual(expected) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts index 959a02919a23..a96388ac4ca3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -84,6 +84,8 @@ export function hostTestDrawnRowIds( state: 'dispatching' as const })) return projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions) + projectStructuredAgentSessionMessages(snapshot.items, outbox, snapshot.submissions, { + rejectedInPlace: true + }) ).map(({ id }) => id) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c88..bd97cf2b21f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -17,7 +17,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-mutation' import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, structuredAgentSessionCompactBody @@ -33,16 +33,6 @@ import { import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' -/** The body-only hash: what the reducer recomputes to alias a provider echo - * onto its submission, so the stored value must never include control fields. */ -function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - export type MutationPlan = { method: string fields: Record @@ -87,7 +77,10 @@ export function sendPlan(params: { return performSend(ctx, { origin: params.userSend ? 'client' : 'host', clientMessageId, - payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), + payloadFingerprint: structuredAgentSessionSendBodyFingerprint( + params.envelope.sessionId, + params.body + ), body: params.body }) }, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx index 74df2b75bd6c..7efb254c6194 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.stream-render.perf.test.tsx @@ -143,7 +143,9 @@ describe('native chat transcript re-render cost during a streaming turn', () => } const view = (items: AgentJournalRenderItem[]) => ( { + const LOST = agentJournalSubmissionKey('lost') + + function hostList(phase: Phase) { + const items = journal(phase, true) + const newIndex = items.findIndex((item) => item.itemId === NEW) + // Recorded between the seed's answer and the newer prompt; the next open rejected it. + items.splice(newIndex, 0, { + itemId: LOST, + revision: 0, + sequence: items[newIndex - 1]!.sequence, + sequenceIndex: 1, + observedAt: 1002.5, + body: said('user', 'LOST PROMPT'), + turnScope: { kind: 'thread' } + }) + const submissions: AgentJournalSubmission[] = [ + submission('seed', 'accepted'), + { + ...submission('lost', 'rejected'), + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + }, + submission('new', phase === 'done' ? 'accepted' : 'pending') + ] + const settledTurns: NativeChatSettledTurns = new Map([ + [SEED, { startedAt: 1, workedSeconds: 3 }], + ...(phase === 'done' ? [[NEW, { startedAt: 2, workedSeconds: 5 }] as const] : []) + ]) + return ( + + ) + } + + it('draws it where it was sent, with its reason and no Retry, outside the newer turn', () => { + const { container, rerender } = render(hostList('running')) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'LOST PROMPT', + 'NEW PROMPT', + 'WORKING', + 'ACTIVITY' + ]) + expect(container.textContent).toContain('Orca restarted before this message was sent.') + expect(container.querySelector('button[aria-label="Retry"]')).toBeNull() + expect( + [...container.querySelectorAll('button')].map((button) => button.textContent) + ).not.toContain('Retry') + rerender(hostList('done')) + expect(drawn(container)).toEqual([ + 'SEED PROMPT', + 'WORKED', + 'LOST PROMPT', + 'NEW PROMPT', + 'WORKED' + ]) + expect(container.textContent).toContain('Worked for 5s') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 6e39ec0129de..4d81b390983b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -150,7 +150,9 @@ export function createStructuredSessionMocks() { messages: mocks.messages ?? (mocks.mode === 'outbox' - ? projectStructuredAgentSessionMessages([], outbox.outbox, []) + ? projectStructuredAgentSessionMessages([], outbox.outbox, [], { + rejectedInPlace: true + }) : [ { id: 'message-1', diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 6b79c8a7fc89..870c1fe47862 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -123,7 +123,9 @@ export function NativeChatStructuredSession( }, []) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') // Only a rejected message 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 hasRejected = + controller.outbox.some((entry) => entry.state === 'rejected') || + controller.submissions.some((submission) => submission.dispatchState === 'rejected') const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS const startFailures = useStructuredAgentSessionStartFailureFacts( controller.journalItems, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 460df23ef8b3..aebf5798a329 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -62,7 +62,9 @@ vi.mock('./use-structured-agent-session', async () => { journalItems: [], messages: mocks.mode === 'outbox' - ? projectStructuredAgentSessionMessages([], outbox.outbox, []) + ? projectStructuredAgentSessionMessages([], outbox.outbox, [], { + rejectedInPlace: true + }) : [ { id: 'message-1', diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index e5254b63e942..f57520af638e 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -17,7 +17,7 @@ import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' -import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } @@ -65,10 +65,11 @@ const JOURNAL: AgentJournalRenderItem[] = [ { ...user(12, [{ type: 'text', text: 'Observed earlier' }]), observedAt: 1_009.5 } ] -/** The renderer's own path from journal items to rail items, as the list runs it. */ +/** The renderer's own path from journal items to rail items, as the list runs it. The outline + * lists no rejected message: the desktop draws those in place, and ticks them once loaded. */ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { const projected = createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, [], submissions) + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) ).conversation const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) let turn: string | undefined 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 6ad655e8f80c..75d2dfcfc8d1 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 @@ -10,7 +10,8 @@ // A message the host recorded and then rejected is worded from the journal's own fact, found by id; // the message keeps only a smaller copy, read when its submission is not loaded. A rejection that // is a failed start's, the fact its loaded row states, says only that it was not sent: the row -// already says why. +// already says why. One the outbox no longer holds is drawn from the host's history and gets the +// same words, with no Retry: sending it again is a new message. import { readAgentSessionFailureFact, @@ -33,7 +34,11 @@ import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' import type { AgentSessionFailureWordsContext } from '../../../../shared/agent-session-failure-words' -import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' +import { + structuredAgentSessionAttemptFailureParts, + structuredAgentSessionRejectionParts +} from '../../../../shared/structured-agent-session-send-disposition' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' @@ -129,8 +134,26 @@ function deliveryNoticeText( ) } +function hostRejectionNoticeText( + submission: AgentJournalSubmission, + agentName: string, + startFailures: readonly AgentSessionFailureFact[] +): string { + if (agentSessionFailureStatedByStartRow(submission.rejection, startFailures)) { + return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) + } + return agentSessionWriteNoticeText( + structuredAgentSessionRejectionParts( + submission.reason, + 'send', + readWholeAgentSessionFailureFact(submission.rejection), + { agentName } + ) + ) +} + /** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's - * agent, for the words. */ + * agent, for the words. A rejection the transcript does not draw has a notice nothing reads. */ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, @@ -172,5 +195,13 @@ export function structuredAgentSessionDeliveryNotices( ) } } + const inOutbox = new Set(outbox.map((entry) => entry.clientMessageId)) + for (const submission of rejected.values()) { + if (!inOutbox.has(submission.clientMessageId) && !dispatchWasWithdrawn(submission)) { + notices.set(agentJournalSubmissionKey(submission.clientMessageId), { + text: hostRejectionNoticeText(submission, agentName, startFailures) + }) + } + } return notices } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts new file mode 100644 index 000000000000..dac875835da5 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -0,0 +1,317 @@ +// A message the host accepted and then rejected stays in the desktop's chat where it was sent, +// marked not sent, from the host's own history: a crash can lose the outbox, never the host's row. + +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + DISPATCH_REJECTED_CANCELLED, + DISPATCH_REJECTED_HOST_RESTARTED +} from '../../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages as projectShared } from '../../../../shared/structured-agent-session-message-projection' +import { structuredAgentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-mutation' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const SESSION = 'session-1' + +function body(text: string) { + return { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } +} + +function fingerprint(text: string): string { + return structuredAgentSessionSendBodyFingerprint(SESSION, body(text)) +} + +function userItem(id: string, sequence: number, text: string): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + body: body(text) + } +} + +function answer(sequence: number): AgentJournalRenderItem { + return { + itemId: `answer-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +function submission( + id: string, + text: string, + submittedAt: number, + patch: Partial = {} +): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: fingerprint(text), + dispatchState: 'accepted', + providerItemId: `provider-${id}`, + reason: null, + submittedAt, + resolvedAt: submittedAt, + ...patch + } +} + +/** Rejected on the next open after a crash, before the agent ever got it. */ +function restartRejected(id: string, text: string, submittedAt: number): AgentJournalSubmission { + return submission(id, text, submittedAt, { + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + }) +} + +function withdrawn(id: string, text: string, submittedAt: number): AgentJournalSubmission { + return submission(id, text, submittedAt, { + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } + }) +} + +function outboxEntry( + id: string, + text: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: SESSION, + text, + attachments: [], + queuedAt: 50 + }), + ...patch + } +} + +function rows(messages: ReturnType) { + return messages + .filter((message) => message.role === 'user') + .map((message) => ({ + id: message.id, + text: message.blocks[0]?.type === 'text' ? message.blocks[0].text : null, + unsent: message.unsent ?? false + })) +} + +const SEED = submission('seed', 'seed', 1) +const SEED_ROWS = [userItem('seed', 1, 'seed'), answer(2)] + +describe('a message the host accepted and then rejected, on the desktop', () => { + it('stays where it was sent, as not sent, with no outbox entry left after a crash', () => { + const items = [...SEED_ROWS, userItem('lost', 3, 'fix the parser')] + const messages = projectStructuredAgentSessionMessages( + items, + [], + [SEED, restartRejected('lost', 'fix the parser', 3)] + ) + + expect(rows(messages)).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('lost'), text: 'fix the parser', unsent: true } + ]) + // Its place is the host's: the row keeps the journal position it was recorded at. + expect( + messages.find((message) => message.id === agentJournalSubmissionKey('lost')) + ).toMatchObject({ journalPosition: { sequence: 3, index: 0 }, source: 'transcript' }) + }) + + it('says why from the host fact, with no Retry', () => { + const notices = structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + [SEED, restartRejected('lost', 'fix the parser', 3)], + [], + new Set() + ) + + const notice = notices.get(agentJournalSubmissionKey('lost')) + expect(notice?.text).toBe('Orca restarted before this message was sent.') + expect(notice?.onRetry).toBeUndefined() + expect([...notices.keys()]).toEqual([agentJournalSubmissionKey('lost')]) + }) + + it("says only that it was not sent when the failed start's row already says why", () => { + const failedStart = submission('first', 'hello', 3, { + dispatchState: 'rejected', + providerItemId: null, + reason: 'Claude is not signed in.', + rejection: { kind: 'notSignedIn' } + }) + const notices = structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + [failedStart], + [{ kind: 'notSignedIn' }], + new Set() + ) + + expect(notices.get(agentJournalSubmissionKey('first'))).toEqual({ + text: 'Your message was not sent.' + }) + }) + + it('is hidden when a later message in the chat has the same body', () => { + // An earlier build's Retry resent it under a new id, and that copy was delivered. + const items = [...SEED_ROWS, userItem('old', 3, 'retry me'), userItem('resent', 4, 'retry me')] + const submissions = [ + SEED, + restartRejected('old', 'retry me', 3), + submission('resent', 'retry me', 4) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false } + ]) + }) + + it('stays when the same body was only sent before it, or by a copy a Stop withdrew', () => { + const items = [ + ...SEED_ROWS, + userItem('first', 3, 'again'), + userItem('failed', 4, 'again'), + userItem('stopped', 5, 'again') + ] + const submissions = [ + SEED, + submission('first', 'again', 3), + restartRejected('failed', 'again', 4), + withdrawn('stopped', 'again', 5) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, + { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } + ]) + }) + + it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { + const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] + const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + ]) + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size + ).toBe(0) + }) +}) + +describe('one row per rejected message while the outbox still holds it', () => { + const items = [...SEED_ROWS, userItem('held', 3, 'host copy')] + const rejected = restartRejected('held', 'outbox copy', 3) + const held = outboxEntry('held', 'outbox copy', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: DISPATCH_REJECTED_HOST_RESTARTED } + }) + const heldRow = { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true } + const hostRow = { id: agentJournalSubmissionKey('held'), text: 'host copy', unsent: true } + const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + + it('outbox first: the host rejecting it leaves the outbox row, with its Retry, in the host place', () => { + const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined } + expect(rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED]))).toEqual([ + seedRow, + { ...heldRow, unsent: false } + ]) + + const messages = projectStructuredAgentSessionMessages(items, [held], [SEED, rejected]) + expect(rows(messages)).toEqual([seedRow, heldRow]) + expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 }) + const notices = structuredAgentSessionDeliveryNotices( + [held], + 'Claude', + () => {}, + [SEED, rejected], + [], + new Set() + ) + expect(notices.get(heldRow.id)?.onRetry).toBeDefined() + + // The outbox lets it go: the host's row takes over, still one row. + expect(rows(projectStructuredAgentSessionMessages(items, [], [SEED, rejected]))).toEqual([ + seedRow, + hostRow + ]) + }) + + it('host first: the outbox picking it up afterwards replaces the host row', () => { + expect(rows(projectStructuredAgentSessionMessages(items, [], [SEED, rejected]))).toEqual([ + seedRow, + hostRow + ]) + expect(rows(projectStructuredAgentSessionMessages(items, [held], [SEED, rejected]))).toEqual([ + seedRow, + heldRow + ]) + }) + + it("hides it behind the outbox's resend under a new id until the host records that", () => { + const hostItems = [...SEED_ROWS, userItem('held', 3, 'outbox copy')] + const resend = outboxEntry('resend', 'outbox copy') + expect( + rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, rejected])) + ).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } + ]) + + // Its Retry was refused before the host recorded it: still the one outbox row. + const refused = { + ...resend, + lastAttemptAt: 60, + lastFailure: { kind: 'refused' as const, code: 'agent_session_journal_unreadable' as const } + } + expect( + rows(projectStructuredAgentSessionMessages(hostItems, [refused], [SEED, rejected])) + ).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: true } + ]) + }) +}) + +describe('the phone', () => { + it('still hides every accepted-then-rejected message: it gives the text back to its composer', () => { + const items = [ + ...SEED_ROWS, + userItem('lost', 3, 'fix the parser'), + userItem('stopped', 4, 'never mind') + ] + const submissions = [ + SEED, + restartRejected('lost', 'fix the parser', 3), + withdrawn('stopped', 'never mind', 4) + ] + + expect(rows(projectShared(items, [], submissions, { rejectedInPlace: false }))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 3a3562458cb6..6b4a74194e25 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -31,15 +31,6 @@ function item(index: number): AgentJournalRenderItem { } describe('structured agent session message projection', () => { - it('does not render a rejected host submission as a sent user message', () => { - const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null } - const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) } - const acceptedItem = item(1) - expect( - projectStructuredAgentSessionMessages([refusedItem, acceptedItem], [], [rejected]) - ).toMatchObject([{ id: acceptedItem.itemId, role: 'user' }]) - }) - it('keeps a refused local draft available through its outbox', () => { const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null } const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) } 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 0ecfd7aa2f5e..20eaf84da903 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 @@ -6,12 +6,20 @@ import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/struc import { projectStructuredAgentSessionMessages as projectMessages } from '../../../../shared/structured-agent-session-message-projection' import { projectStructuredQuestionMessages } from './structured-agent-question-projection' +/** The desktop's transcript: a message the host accepted and then rejected stays where it was + * sent, as not sent. */ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[] ) { - return projectMessages(items, outbox, submissions, projectStructuredQuestionMessages) + return projectMessages( + items, + outbox, + submissions, + { rejectedInPlace: true }, + projectStructuredQuestionMessages + ) } export type StructuredPromptItem = AgentJournalRenderItem & { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx new file mode 100644 index 000000000000..063c56873519 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment happy-dom + +// A send whose own reply says the host recorded it and then rejected it is shown once, in the chat +// where it was sent; its text never also goes back to the composer. + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { DISPATCH_REJECTED_NOT_DELIVERED } from '../../../../shared/structured-agent-session-dispatch-rejection' + +type SendParams = { envelope: { clientOperationId: string; payloadFingerprint: string } } + +const mocks = vi.hoisted(() => ({ + call: vi.fn<(target: unknown, method: string, params: SendParams) => Promise>() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { + clearNativeChatDraftCacheForTests, + readNativeChatDraftCache +} from './native-chat-draft-cache' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +afterEach(cleanup) + +beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + clearNativeChatDraftCacheForTests() +}) + +it('draws a send its reply rejected in place once, and leaves the composer empty', async () => { + const reply: { submission: AgentJournalSubmission | null } = { submission: null } + mocks.call.mockImplementationOnce(async (_target, _method, { envelope }) => { + const rejected: AgentJournalSubmission = { + clientMessageId: envelope.clientOperationId, + fence: 1, + payloadFingerprint: envelope.payloadFingerprint, + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_NOT_DELIVERED, + rejection: { kind: 'notDelivered' }, + submittedAt: 10, + resolvedAt: 11 + } + reply.submission = rejected + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { clientMessageId: envelope.clientOperationId, submission: rejected } + } + }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [], + composerScopeKey: 'pane-1' + }) + ) + + act(() => expect(result.current.send('steer this way')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + expect(readNativeChatDraftCache('pane-1')).toBe('') + + const submission = reply.submission + if (!submission) { + throw new Error('the send was never answered') + } + const hostItem: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey(submission.clientMessageId), + revision: 1, + sequence: 10, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'steer this way' }] } + } + const users = (outbox: typeof result.current.outbox) => + projectStructuredAgentSessionMessages([hostItem], outbox, [submission]) + .filter((message) => message.role === 'user') + .map((message) => ({ id: message.id, unsent: message.unsent })) + + expect(users(result.current.outbox)).toEqual([{ id: hostItem.itemId, unsent: true }]) + // After a crash takes the outbox, the host's row is still the one row. + expect(users([])).toEqual([{ id: hostItem.itemId, unsent: true }]) + expect(readNativeChatDraftCache('pane-1')).toBe('') +}) diff --git a/src/shared/agent-session-conversation-outline.ts b/src/shared/agent-session-conversation-outline.ts index 2b6bb846adb8..4d6b814d64d3 100644 --- a/src/shared/agent-session-conversation-outline.ts +++ b/src/shared/agent-session-conversation-outline.ts @@ -89,7 +89,8 @@ export function projectAgentSessionConversationOutline( } const entries: AgentSessionConversationOutlineEntry[] = [] const transcript = projectNativeChatTranscriptMessages( - projectStructuredAgentSessionMessages(items, [], submissions) + // Unchanged on the wire: a desktop's rejected rows tick once their page is loaded. + projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) ) for (const message of transcript) { const sequence = sequences.get(message.id) diff --git a/src/shared/native-chat-provider-retry-runs.test.ts b/src/shared/native-chat-provider-retry-runs.test.ts index 0dff50c5f1db..3bb0c2d02271 100644 --- a/src/shared/native-chat-provider-retry-runs.test.ts +++ b/src/shared/native-chat-provider-retry-runs.test.ts @@ -49,7 +49,7 @@ function texts(messages: readonly NativeChatMessage[]): string[] { } function drawn(items: AgentJournalRenderItem[]): string[] { - return texts(projectStructuredAgentSessionMessages(items, [], [])) + return texts(projectStructuredAgentSessionMessages(items, [], [], { rejectedInPlace: true })) } describe('a run of provider retry rows', () => { @@ -101,7 +101,8 @@ describe('a run of provider retry rows', () => { retry(2, 'subagent-1') ], [], - [] + [], + { rejectedInPlace: true } ) ) expect(texts(conversation)).toEqual([ @@ -136,7 +137,8 @@ describe('a run of provider retry rows', () => { resolvedAt: null, handoverRecorded: true } - ] + ], + { rejectedInPlace: true } ) expect(messages.map((message) => [message.id, message.queued ?? false])).toEqual([ [expect.stringMatching(/^item-/), false], diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index db7b7024cdc1..13ec0de064a7 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -212,8 +212,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { sentAs?: AgentJournalMessageSendMode /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ queued?: true - /** Shown as not sent, waiting for the user's Retry: in no turn, so a newer turn's bar and clock - * never land on it, and drawn after the conversation. */ + /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where + * the journal recorded it, or after the conversation when it holds no place there. */ unsent?: true /** Set only by the structured projection, on rows the journal holds, and ranks * them ahead of time. Terminal-backed messages never carry it, and worker reads strip it. */ diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 5515b6478a22..456bd20ed9fb 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,31 +1,96 @@ -import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import type { NativeChatMessage } from './native-chat-types' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { structuredAgentSessionSendBodyFingerprint } from './structured-agent-session-mutation' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { reconcileStructuredAgentSessionOutboxWithQueue } from './structured-agent-session-draft-hand-off' import { projectStructuredItemsToNativeChat } from './structured-agent-session-projection' +export type StructuredAgentSessionMessageProjectionOptions = { + /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. + * Off for a client that hands such a message back to its composer instead. */ + rejectedInPlace: boolean +} + +const outboxBodyFingerprints = new WeakMap() + +function outboxBodyFingerprint(entry: StructuredAgentSessionOutboxEntry): string { + let fingerprint = outboxBodyFingerprints.get(entry.body) + if (fingerprint === undefined) { + fingerprint = structuredAgentSessionSendBodyFingerprint(entry.sessionId, entry.body) + outboxBodyFingerprints.set(entry.body, fingerprint) + } + return fingerprint +} + +/** + * The rejected submissions the host's history shows in place, by item id. A withdrawn one went + * back to its sender; one the outbox still draws is drawn once, by the outbox; and one with a later + * copy of the same body is superseded by it — earlier builds resent a rejected message under a new + * id, so without this an update would surface every resent copy. + */ +function rejectedShownInPlace( + submissions: readonly AgentJournalSubmission[], + outbox: readonly StructuredAgentSessionOutboxEntry[] +): Set { + const shown = new Set() + const outboxIds = new Set(outbox.map((entry) => entry.clientMessageId)) + // Submissions arrive ordered by submission time. A withdrawn copy is hidden too, so it + // supersedes nothing. + const lastCopy = new Map() + for (const [index, submission] of submissions.entries()) { + if (!dispatchWasWithdrawn(submission)) { + lastCopy.set(submission.payloadFingerprint, index) + } + } + for (const [index, submission] of submissions.entries()) { + if ( + submission.dispatchState !== 'rejected' || + dispatchWasWithdrawn(submission) || + outboxIds.has(submission.clientMessageId) || + (lastCopy.get(submission.payloadFingerprint) ?? index) > index || + outbox.some((entry) => outboxBodyFingerprint(entry) === submission.payloadFingerprint) + ) { + continue + } + shown.add(agentJournalSubmissionKey(submission.clientMessageId)) + } + return shown +} + export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], + options: StructuredAgentSessionMessageProjectionOptions, projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) - // Refused sends are ledger evidence, not conversation history; local drafts remain in the outbox. + // Refused sends are ledger evidence, not conversation history, unless drawn in place as not sent. const rejected = new Set( submissions .filter((submission) => submission.dispatchState === 'rejected') .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) + const inPlace = options.rejectedInPlace + ? rejectedShownInPlace(submissions, optimistic) + : new Set() const visibleItems: AgentJournalRenderItem[] = [] + const unsentItems: AgentJournalRenderItem[] = [] const refused = new Map() for (const item of items) { - if (rejected.has(item.itemId)) { + if (inPlace.has(item.itemId)) { + unsentItems.push(item) + } else if (rejected.has(item.itemId)) { refused.set(item.itemId, item) } else { visibleItems.push(item) @@ -52,6 +117,8 @@ export function projectStructuredAgentSessionMessages( // After the held sends leave: they are drawn after the conversation, never inside a run. ...collapseProviderRetryRuns(delivered), ...held, + // In no turn, like the outbox's not-sent rows; the journal position keeps their place. + ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) .map((entry): NativeChatMessage => { diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 4c25de1a1bdc..f3d9b848c697 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,3 +1,4 @@ +import type { AgentJournalMessageItem } from './agent-session-journal-types' import { sha256 } from './sha256' function canonicalize(value: unknown): string { @@ -26,6 +27,19 @@ export function structuredAgentSessionPayloadFingerprint(input: { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') } +/** The body-only hash a send's submission row stores: what the reducer recomputes to alias a + * provider echo onto its submission, so it must never include control fields. */ +export function structuredAgentSessionSendBodyFingerprint( + sessionId: string, + body: AgentJournalMessageItem +): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + export function structuredAgentSessionDomainFingerprint(input: { domain: string sessionId: string From 4fd1c777786e75e4dac63baad69a0d403e2fcf6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:28:09 -0700 Subject: [PATCH 02/19] test(native-chat): pin the same-id rule apart from the body match --- ...t-session-message-projection.rejected-in-place.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index dac875835da5..eb611615841b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -225,7 +225,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => describe('one row per rejected message while the outbox still holds it', () => { const items = [...SEED_ROWS, userItem('held', 3, 'host copy')] - const rejected = restartRejected('held', 'outbox copy', 3) + // Fingerprinted from the host's own copy, so only the shared id ties the two rows together. + const rejected = restartRejected('held', 'host copy', 3) const held = outboxEntry('held', 'outbox copy', { state: 'rejected', lastFailure: { kind: 'rejected', reason: DISPATCH_REJECTED_HOST_RESTARTED } @@ -274,9 +275,10 @@ describe('one row per rejected message while the outbox still holds it', () => { it("hides it behind the outbox's resend under a new id until the host records that", () => { const hostItems = [...SEED_ROWS, userItem('held', 3, 'outbox copy')] + const resent = restartRejected('held', 'outbox copy', 3) const resend = outboxEntry('resend', 'outbox copy') expect( - rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, rejected])) + rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent])) ).toEqual([ seedRow, { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } @@ -289,7 +291,7 @@ describe('one row per rejected message while the outbox still holds it', () => { lastFailure: { kind: 'refused' as const, code: 'agent_session_journal_unreadable' as const } } expect( - rows(projectStructuredAgentSessionMessages(hostItems, [refused], [SEED, rejected])) + rows(projectStructuredAgentSessionMessages(hostItems, [refused], [SEED, resent])) ).toEqual([ seedRow, { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: true } From 2003f481be65f5c05261386f9df96e05436f8cde Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:32:10 -0700 Subject: [PATCH 03/19] test(native-chat): type the rejected-in-place fixture body as a text block --- ...ent-session-message-projection.rejected-in-place.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index eb611615841b..2c8084106903 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -23,7 +23,11 @@ import { projectStructuredAgentSessionMessages } from './structured-agent-sessio const SESSION = 'session-1' function body(text: string) { - return { kind: 'message' as const, role: 'user' as const, blocks: [{ type: 'text', text }] } + return { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text }] + } } function fingerprint(text: string): string { From 13a2ef8538dbedc6c3da6450ecc966985883cbc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:12:30 -0700 Subject: [PATCH 04/19] fix(native-chat): let the host's row own a message it recorded and then rejected Once the host's journal records a send as rejected, the desktop outbox lets it go, as it already does for delivered and Stop-withdrawn sends: the host's row shows it as not sent, with the host's reason and no Retry. The outbox keeps only sends the host refused before recording them, which keep their Retry. A send whose own reply says it was rejected is drawn by its outbox entry, with no Retry, until the journal carries the row; a copy left by an earlier session is dropped when the chat opens. - the transcript no longer hides a host row behind an outbox entry with the same id or the same text; those rules and their cache are gone - a rejected message the queue holds (a draft's hand-off, or a live card under its id) is drawn as its card, not as a row - a later copy of the same text hides a rejected row only when it was sent once the rejection was known, so a deliberate repeat stays - delivery notices read the same visibility rule as the transcript; a chat whose only rejection a Stop withdrew no longer rebuilds them per batch - the body fingerprint helper goes back to the host, its only user --- ...structured-agent-session-mutation-plans.ts | 17 +- ...turedSession.start-failure-notice.test.tsx | 13 +- .../NativeChatStructuredSession.tsx | 53 ++----- ...tiveChatStructuredSessionDelivery.test.tsx | 67 +++++--- .../native-chat-rail-outline-parity.test.ts | 14 +- ...red-agent-session-delivery-notices.test.ts | 53 ++----- ...ructured-agent-session-delivery-notices.ts | 57 +++---- ...ssage-projection.rejected-in-place.test.ts | 146 +++++++++++++----- ...d-agent-session-message-projection.test.ts | 13 +- ...ctured-agent-session-message-projection.ts | 7 +- ...tructured-agent-session-outbox-dispatch.ts | 12 +- .../structured-agent-session-outbox-retry.ts | 19 +-- ...session-withdrawn-message-restore.test.tsx | 4 +- ...ed-agent-session-delivery-notices.test.tsx | 50 ++++++ ...ructured-agent-session-delivery-notices.ts | 55 +++++++ ...structured-agent-session-messages.test.tsx | 8 +- .../use-structured-agent-session-messages.ts | 7 +- ...nt-session-outbox-rejection-cause.test.tsx | 52 +++---- ...e-structured-agent-session-outbox.test.tsx | 84 ++++------ .../use-structured-agent-session.ts | 5 +- ...ctured-agent-session-message-projection.ts | 94 +++++------ .../structured-agent-session-mutation.ts | 14 -- src/shared/structured-agent-session-outbox.ts | 39 ++--- ...ructured-agent-session-send-disposition.ts | 6 +- 24 files changed, 477 insertions(+), 412 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index bd97cf2b21f0..a81ea5270c88 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -17,7 +17,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { structuredAgentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, structuredAgentSessionCompactBody @@ -33,6 +33,16 @@ import { import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' +/** The body-only hash: what the reducer recomputes to alias a provider echo + * onto its submission, so the stored value must never include control fields. */ +function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + export type MutationPlan = { method: string fields: Record @@ -77,10 +87,7 @@ export function sendPlan(params: { return performSend(ctx, { origin: params.userSend ? 'client' : 'host', clientMessageId, - payloadFingerprint: structuredAgentSessionSendBodyFingerprint( - params.envelope.sessionId, - params.body - ), + payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) }, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 6f529c1d3c9f..92548887f0af 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -82,8 +82,8 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi } } +// The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. function renderPane(messages: ReturnType[]): void { - mocks.mode = 'outbox' mocks.submissions = messages.map((message) => message.submission) localStorage.setItem( `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, @@ -113,8 +113,9 @@ async function notice(clientMessageId: string): Promise { }) } -// The start's own row says why, so its rejected messages say only that they were not sent. -it("says only 'not sent', with its Retry, on each message the failed start's row explains", async () => { +// The start's own row says why, so its rejected messages say only that they were not sent. Sending +// one again is a new message, so none offers a Retry. +it("says only 'not sent', with no Retry, on each message the failed start's row explains", async () => { mocks.journalItems = [startFailureRow(START_FAILED)] renderPane([ @@ -125,7 +126,7 @@ it("says only 'not sent', with its Retry, on each message the failed start's row for (const id of ['first', 'second']) { const row = await notice(id) expect(within(row).getByText('Your message was not sent.')).toBeTruthy() - expect(within(row).getByRole('button', { name: 'Retry' })).toBeTruthy() + expect(within(row).queryByRole('button', { name: 'Retry' })).toBeNull() } expect(screen.queryByText(/stopped before it finished starting/)).toBeNull() }) @@ -157,7 +158,5 @@ it('keeps the full notice on a message rejected for a reason no start-failure ro it("keeps the start failure's own words when its row is not loaded", async () => { renderPane([rejected('first', START_FAILED_REASON, START_FAILED)]) - expect( - within(await notice('first')).getByText('Claude stopped before it finished starting.') - ).toBeTruthy() + expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 870c1fe47862..96cc16b15ba3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer' import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' @@ -28,11 +28,7 @@ import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' import { NativeChatThreadGoalBanner } from './NativeChatThreadGoalBanner' import { structuredAgentSessionReadFailureNotice } from './structured-agent-session-read-failure-notice' -import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' - -const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] +import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent-session-delivery-notices' export function NativeChatStructuredSession( props: Omit @@ -113,43 +109,16 @@ export function NativeChatStructuredSession( }), [controller, props.agent, props.sessionId] ) - // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. - const retryRef = useRef(controller.retry) - useEffect(() => { - retryRef.current = controller.retry - }) - const retryDelivery = useCallback((clientMessageId: string) => { - retryRef.current(clientMessageId) - }, []) const agentLabel = structuredAgentLabel(props.agent === 'codex' ? 'codex' : 'claude') - // Only a rejected message 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') || - controller.submissions.some((submission) => submission.dispatchState === 'rejected') - const rejectionRows = hasRejected ? controller.submissions : NO_SUBMISSIONS - const startFailures = useStructuredAgentSessionStartFailureFacts( - controller.journalItems, - hasRejected - ) - const deliveryNotices = useMemo( - () => - structuredAgentSessionDeliveryNotices( - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.failedHere - ), - [ - controller.outbox, - agentLabel, - retryDelivery, - rejectionRows, - startFailures, - controller.failedHere - ] - ) + const deliveryNotices = useStructuredAgentSessionDeliveryNotices({ + outbox: controller.outbox, + submissions: controller.submissions, + journalItems: controller.journalItems, + failedHere: controller.failedHere, + queuedMessageIds: controller.queuedMessageIds, + retry: controller.retry, + agentName: agentLabel + }) const viewState = selectNativeChatViewState(session, { readRetries: true }) const readFailure = controller.status === 'error' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index aebf5798a329..68012334116b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -187,6 +187,8 @@ vi.mock('./NativeChatQuestionCard', () => ({ import { NativeChatStructuredSession } from './NativeChatStructuredSession' import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' +const REFUSED_RESTART = "The agent couldn't restart. Your message was not sent." + describe('NativeChatStructuredSession delivery', () => { afterEach(() => { cleanup() @@ -310,10 +312,11 @@ describe('NativeChatStructuredSession delivery', () => { }) seedOutbox('session-held-rejected', [ seededEntry('session-held-rejected', 'op-head', 'first', 'unconfirmed'), + // Refused before the host recorded it, so its Retry is the only way it goes again. { ...seededEntry('session-held-rejected', 'op-rejected', 'second', 'queued'), state: 'rejected', - lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } } ]) @@ -329,7 +332,7 @@ describe('NativeChatStructuredSession delivery', () => { ) await waitFor(() => expect(screen.getByText('Message delivery is unconfirmed.')).toBeTruthy()) - expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + expect(screen.getByText(REFUSED_RESTART)).toBeTruthy() // One Retry, the stopped message's: it sends only that one. fireEvent.click(screen.getByRole('button', { name: /Retry/ })) await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) @@ -339,27 +342,38 @@ describe('NativeChatStructuredSession delivery', () => { // The queue moved, so the rejected message offers its own Retry again. await waitFor(() => expect(screen.queryByText('Message delivery is unconfirmed.')).toBeNull()) - expect(screen.getByText('Claude messages support at most 20 images')).toBeTruthy() + expect(screen.getByText(REFUSED_RESTART)).toBeTruthy() expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(1) expect(mocks.call).toHaveBeenCalledOnce() }) - it("words a failed start on each message by the chat's agent, leaving the resend to its Retry", async () => { + // Until the journal carries the row, the message's own copy words it; the host has it, so no Retry. + it("words a failed start its reply rejected by the chat's agent, with no Retry", async () => { mocks.mode = 'outbox' mocks.submissions = [] - const startFailed = (clientMessageId: string, text: string) => ({ - ...seededEntry('session-start-failed', clientMessageId, text, 'queued'), - state: 'rejected' as const, - lastFailure: { - kind: 'rejected' as const, - reason: 'Codex stopped before it finished starting. Send your message to try again.', - rejection: { kind: 'providerStartFailed' as const } - } - }) - seedOutbox('session-start-failed', [ - startFailed('op-first', 'first'), - startFailed('op-second', 'second') - ]) + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => ({ + ok: true, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: { + clientMessageId: params.envelope.clientOperationId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Codex stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' }, + submittedAt: 1, + resolvedAt: 2 + } + } + }) + ) render( { /> ) + const send = mocks.composerProps?.structuredTransport?.send as + | ((text: string, attachments: readonly { id: string; path: string }[]) => boolean) + | undefined + expect(send?.('first', [])).toBe(true) await waitFor(() => - expect(screen.getAllByText('Codex stopped before it finished starting.')).toHaveLength(2) + expect( + screen.getByText( + 'Codex stopped before it finished starting. Send your message to try again.' + ) + ).toBeTruthy() ) - expect(screen.getAllByRole('button', { name: /Retry/ })).toHaveLength(2) - expect(screen.queryByText(/Send your message to try again/)).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() + expect(mocks.call).toHaveBeenCalledOnce() }) - it("words a rejected message from its loaded journal row, not the message's own copy", async () => { - mocks.mode = 'outbox' + // A copy an earlier session left in the outbox gives way to the host's row. + it("words a rejected message from its journal row, not the message's own copy, with no Retry", async () => { const reason = "Claude couldn't start. Send your message to try again." mocks.submissions = [ { @@ -421,6 +443,7 @@ describe('NativeChatStructuredSession delivery', () => { expect(screen.getByText("Codex couldn't start. Start a new chat to continue.")).toBeTruthy() ) expect(screen.queryByText(reason)).toBeNull() + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull() }) it('names the stuck message behind an admitted head, and its Retry sends that one', async () => { diff --git a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts index f57520af638e..6d4e0b23a784 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -17,7 +17,7 @@ import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' -import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } @@ -65,11 +65,10 @@ const JOURNAL: AgentJournalRenderItem[] = [ { ...user(12, [{ type: 'text', text: 'Observed earlier' }]), observedAt: 1_009.5 } ] -/** The renderer's own path from journal items to rail items, as the list runs it. The outline - * lists no rejected message: the desktop draws those in place, and ticks them once loaded. */ +/** The renderer's own path from journal items to rail items, as the list runs it. */ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { const projected = createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, [], submissions, { rejectedInPlace: false }) + projectStructuredAgentSessionMessages(items, [], submissions) ).conversation const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) let turn: string | undefined @@ -94,9 +93,14 @@ function loadedRailItems(items: AgentJournalRenderItem[], submissions: AgentJour } describe('conversation outline parity with the loaded rail', () => { + // Except a rejected message: the desktop draws it in place and ticks it once loaded, while the + // host's outline, which older clients read too, leaves it out. it('lists exactly the user messages the transcript gives a rail tick, with the same ids and previews', () => { const outline = projectAgentSessionConversationOutline(JOURNAL, [REJECTED]) - const loaded = loadedRailItems(JOURNAL, [REJECTED]) + const rejectedId = agentJournalSubmissionKey(REJECTED.clientMessageId) + const loadedWithRejected = loadedRailItems(JOURNAL, [REJECTED]) + expect(loadedWithRejected.filter((item) => item.id === rejectedId)).toHaveLength(1) + const loaded = loadedWithRejected.filter((item) => item.id !== rejectedId) expect(outline.map((entry) => entry.itemId)).toEqual(loaded.map((item) => item.id)) expect( 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 5dde72621adb..4c655302f7fa 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 @@ -56,7 +56,9 @@ function texts( } describe('the notice on each message that did not go through', () => { - it('gives two failed messages each their own reason and their own Retry', () => { + // Recorded by the host, so sending it again is a new message: no Retry until the journal row + // takes it over. + it('gives two messages the host rejected each their own reason and no Retry', () => { const retry = vi.fn() const notices = structuredAgentSessionDeliveryNotices( [ @@ -90,8 +92,8 @@ describe('the notice on each message that did not go through', () => { expect(notices.get(agentJournalSubmissionKey('second'))?.text).toBe( 'Claude never finished starting, so Orca stopped it.' ) - notices.get(agentJournalSubmissionKey('second'))?.onRetry?.() - expect(retry).toHaveBeenCalledExactlyOnceWith('second') + expect(notices.get(agentJournalSubmissionKey('first'))?.onRetry).toBeUndefined() + expect(notices.get(agentJournalSubmissionKey('second'))?.onRetry).toBeUndefined() }) it('chooses the words from the saved refusal on a refused message', () => { @@ -223,39 +225,15 @@ describe('the notice on each message that did not go through', () => { expect(retry.mock.calls).toEqual([['refused'], ['rejected'], ['stuck']]) }) - // Beside its own Retry the resend step is the button; without one the words keep it. - it('leaves out sending again only where the message has its own Retry', () => { - const startFailed = (clientMessageId: string): StructuredAgentSessionOutboxEntry => - entry(clientMessageId, { - state: 'rejected', - lastFailure: { - kind: 'rejected', - reason: 'Claude stopped before it finished starting. Send your message to try again.', - rejection: { kind: 'providerStartFailed' } - } - }) - expect(texts([startFailed('first'), startFailed('second')])).toEqual({ - [agentJournalSubmissionKey('first')]: 'Claude stopped before it finished starting.', - [agentJournalSubmissionKey('second')]: 'Claude stopped before it finished starting.' - }) - expect(texts([entry('held', { outlivedStop: true }), startFailed('rejected')])).toMatchObject({ - [agentJournalSubmissionKey('rejected')]: - 'Claude stopped before it finished starting. Send your message to try again.' - }) - }) - + // With no Retry beside it, the words keep the resend step. it.each([ [ - 'notDelivered', - 'This message was not delivered. Send it again to continue.', - 'This message was not delivered.' + 'providerStartFailed', + 'Claude stopped before it finished starting. Send your message to try again.' ], - [ - 'hostFault', - "Orca ran into a problem, so this didn't go through. Try again.", - "Orca ran into a problem, so this didn't go through." - ] - ] as const)('leaves the step to the Retry beside a %s message', (kind, reason, shown) => { + ['notDelivered', 'This message was not delivered. Send it again to continue.'], + ['hostFault', "Orca ran into a problem, so this didn't go through. Try again."] + ] as const)('keeps the step in the words of a %s message the host rejected', (kind, reason) => { expect( texts([ entry('rejected', { @@ -263,7 +241,7 @@ describe('the notice on each message that did not go through', () => { lastFailure: { kind: 'rejected', reason, rejection: { kind } } }) ]) - ).toEqual({ [agentJournalSubmissionKey('rejected')]: shown }) + ).toEqual({ [agentJournalSubmissionKey('rejected')]: reason }) }) // The journal holds the whole fact; the message's own copy keeps only its kind and attachment. @@ -281,7 +259,7 @@ describe('the notice on each message that did not go through', () => { const recorded = (id: string, rejection: AgentSessionFailureFact): AgentJournalSubmission => ({ clientMessageId: id, fence: 1, - payloadFingerprint: 'fingerprint', + payloadFingerprint: id, dispatchState: 'rejected', providerItemId: null, reason: "The agent couldn't be started.", @@ -312,7 +290,8 @@ describe('the notice on each message that did not go through', () => { [ 'resumable', { kind: 'startFailed', refusal: { code: 'agent_session_ownership_unknown' } }, - "Claude couldn't start." + // No Retry beside it, so the words keep the step. + "Claude couldn't start. Send your message to try again." ], [ 'provider', @@ -392,7 +371,7 @@ describe('the notice on each message that did not go through', () => { { clientMessageId: 'recorded', fence: 1, - payloadFingerprint: 'fingerprint', + payloadFingerprint: 'recorded', dispatchState: 'rejected', providerItemId: null, reason, 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 75d2dfcfc8d1..f0e35836cf49 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 @@ -7,11 +7,10 @@ // rejected or refused message holds nothing up, so it keeps its words and gets its Retry once the // queue moves. // -// A message the host recorded and then rejected is worded from the journal's own fact, found by id; -// the message keeps only a smaller copy, read when its submission is not loaded. A rejection that -// is a failed start's, the fact its loaded row states, says only that it was not sent: the row -// already says why. One the outbox no longer holds is drawn from the host's history and gets the -// same words, with no Retry: sending it again is a new message. +// A message the host recorded and then rejected is drawn from the host's history, worded from the +// journal's own fact, with no Retry: sending it again is a new message. A rejection that is a +// failed start's, the fact its loaded row states, says only that it was not sent: the row already +// says why. Until the journal carries it, the outbox draws it from its smaller copy of that fact. import { readAgentSessionFailureFact, @@ -27,6 +26,7 @@ import { agentSessionWriteNotDoneParts } from '../../../../shared/agent-session- import { isStructuredAgentSessionStartFailureRow } from '../../../../shared/structured-agent-session-start-failure-row-key' import { structuredAgentSessionEntryIdExpired, + structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { @@ -38,7 +38,7 @@ import { structuredAgentSessionAttemptFailureParts, structuredAgentSessionRejectionParts } from '../../../../shared/structured-agent-session-send-disposition' -import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionRejectedShownInPlace } from '../../../../shared/structured-agent-session-message-projection' import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' @@ -92,8 +92,6 @@ export function agentSessionFailureStatedByStartRow( function deliveryNoticeText( entry: StructuredAgentSessionOutboxEntry, context: AgentSessionFailureWordsContext, - recorded: AgentJournalSubmission | undefined, - startFailures: readonly AgentSessionFailureFact[], failedHere: ReadonlySet ): string { // A send attempted before a Stop and then interrupted may already be with the host. @@ -119,18 +117,8 @@ function deliveryNoticeText( if (structuredAgentSessionEntryHeldForRetry(entry) && !failedHere.has(entry.clientMessageId)) { return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) } - if ( - entry.state === 'rejected' && - agentSessionFailureStatedByStartRow(recorded?.rejection, startFailures) - ) { - return agentSessionWriteNoticeText(agentSessionWriteNotDoneParts('send')) - } return agentSessionWriteNoticeText( - structuredAgentSessionAttemptFailureParts( - entry.lastFailure, - context, - readWholeAgentSessionFailureFact(recorded?.rejection) - ) + structuredAgentSessionAttemptFailureParts(entry.lastFailure, context) ) } @@ -153,7 +141,7 @@ function hostRejectionNoticeText( } /** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's - * agent, for the words. A rejection the transcript does not draw has a notice nothing reads. */ + * agent, for the words. */ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, @@ -163,7 +151,9 @@ export function structuredAgentSessionDeliveryNotices( /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ startFailures: readonly AgentSessionFailureFact[], /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ - failedHere: ReadonlySet + failedHere: ReadonlySet, + /** The queue's live cards, which the transcript leaves a rejected message to. */ + queuedMessageIds: readonly string[] = [] ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -175,32 +165,31 @@ export function structuredAgentSessionDeliveryNotices( ) const notices = new Map() for (const [index, entry] of outbox.entries()) { + // The journal settled it, and the reconcile is about to drop it. + if (rejected.has(entry.clientMessageId)) { + continue + } if ( entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) || entry.clientMessageId === held ) { // Its own Retry is the step, so the words leave out sending again. - const retryControl = stalledFrom === -1 || index <= stalledFrom - const text = deliveryNoticeText( - entry, - { agentName, retryControl }, - rejected.get(entry.clientMessageId), - startFailures, - failedHere - ) + const retryControl = + (stalledFrom === -1 || index <= stalledFrom) && + !structuredAgentSessionEntryRejectedByHost(entry) + const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere) notices.set( agentJournalSubmissionKey(entry.clientMessageId), retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } ) } } - const inOutbox = new Set(outbox.map((entry) => entry.clientMessageId)) + const shown = structuredAgentSessionRejectedShownInPlace(submissions, queuedMessageIds) for (const submission of rejected.values()) { - if (!inOutbox.has(submission.clientMessageId) && !dispatchWasWithdrawn(submission)) { - notices.set(agentJournalSubmissionKey(submission.clientMessageId), { - text: hostRejectionNoticeText(submission, agentName, startFailures) - }) + const id = agentJournalSubmissionKey(submission.clientMessageId) + if (shown.has(id)) { + notices.set(id, { text: hostRejectionNoticeText(submission, agentName, startFailures) }) } } return notices diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 2c8084106903..4c4fb89ca53c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -12,7 +12,6 @@ import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' import { projectStructuredAgentSessionMessages as projectShared } from '../../../../shared/structured-agent-session-message-projection' -import { structuredAgentSessionSendBodyFingerprint } from '../../../../shared/structured-agent-session-mutation' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -30,8 +29,9 @@ function body(text: string) { } } +// Same text, same fingerprint, as the host's body-only hash gives. function fingerprint(text: string): string { - return structuredAgentSessionSendBodyFingerprint(SESSION, body(text)) + return `body:${text}` } function userItem(id: string, sequence: number, text: string): AgentJournalRenderItem { @@ -178,7 +178,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => }) }) - it('is hidden when a later message in the chat has the same body', () => { + it('is hidden by a copy of the same body sent once its rejection was known', () => { // An earlier build's Retry resent it under a new id, and that copy was delivered. const items = [...SEED_ROWS, userItem('old', 3, 'retry me'), userItem('resent', 4, 'retry me')] const submissions = [ @@ -193,6 +193,23 @@ describe('a message the host accepted and then rejected, on the desktop', () => ]) }) + it('stays when the same text was sent again before it was rejected', () => { + // "continue", sent twice on purpose; a restart rejected the first only after the second went. + const items = [...SEED_ROWS, userItem('first', 3, 'continue'), userItem('again', 4, 'continue')] + const submissions = [ + SEED, + { ...restartRejected('first', 'continue', 3), resolvedAt: 5 }, + submission('again', 'continue', 4) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false }, + // Listed after the delivered rows; its journal position keeps its place. + { id: agentJournalSubmissionKey('first'), text: 'continue', unsent: true } + ]) + }) + it('stays when the same body was only sent before it, or by a copy a Stop withdrew', () => { const items = [ ...SEED_ROWS, @@ -227,57 +244,63 @@ describe('a message the host accepted and then rejected, on the desktop', () => }) }) -describe('one row per rejected message while the outbox still holds it', () => { +describe("one row per rejected message, the host's once it records the rejection", () => { const items = [...SEED_ROWS, userItem('held', 3, 'host copy')] // Fingerprinted from the host's own copy, so only the shared id ties the two rows together. const rejected = restartRejected('held', 'host copy', 3) const held = outboxEntry('held', 'outbox copy', { state: 'rejected', - lastFailure: { kind: 'rejected', reason: DISPATCH_REJECTED_HOST_RESTARTED } + lastFailure: { + kind: 'rejected', + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' } + } }) const heldRow = { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true } const hostRow = { id: agentJournalSubmissionKey('held'), text: 'host copy', unsent: true } const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } - it('outbox first: the host rejecting it leaves the outbox row, with its Retry, in the host place', () => { - const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined } - expect(rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED]))).toEqual([ - seedRow, - { ...heldRow, unsent: false } - ]) - - const messages = projectStructuredAgentSessionMessages(items, [held], [SEED, rejected]) + it('the reply first: the outbox draws it, saying why, with no Retry', () => { + const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED]) expect(rows(messages)).toEqual([seedRow, heldRow]) - expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 }) - const notices = structuredAgentSessionDeliveryNotices( + const notice = structuredAgentSessionDeliveryNotices( [held], 'Claude', () => {}, - [SEED, rejected], + [SEED], [], new Set() - ) - expect(notices.get(heldRow.id)?.onRetry).toBeDefined() - - // The outbox lets it go: the host's row takes over, still one row. - expect(rows(projectStructuredAgentSessionMessages(items, [], [SEED, rejected]))).toEqual([ - seedRow, - hostRow - ]) + ).get(heldRow.id) + expect(notice?.text).toBe('Orca restarted before this message was sent.') + expect(notice?.onRetry).toBeUndefined() }) - it('host first: the outbox picking it up afterwards replaces the host row', () => { - expect(rows(projectStructuredAgentSessionMessages(items, [], [SEED, rejected]))).toEqual([ - seedRow, - hostRow - ]) - expect(rows(projectStructuredAgentSessionMessages(items, [held], [SEED, rejected]))).toEqual([ + it("the journal first, or next: the host's row replaces the outbox copy at once", () => { + const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined } + expect(rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED]))).toEqual([ seedRow, - heldRow + { ...heldRow, unsent: false } ]) + for (const entry of [dispatching, held]) { + // Before the reconcile drops the entry, the host's row is already the one row. + const messages = projectStructuredAgentSessionMessages(items, [entry], [SEED, rejected]) + expect(rows(messages)).toEqual([seedRow, hostRow]) + expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 }) + const notices = structuredAgentSessionDeliveryNotices( + [entry], + 'Claude', + () => {}, + [SEED, rejected], + [], + new Set() + ) + expect([...notices]).toEqual([ + [hostRow.id, { text: 'Orca restarted before this message was sent.' }] + ]) + } }) - it("hides it behind the outbox's resend under a new id until the host records that", () => { + it("keeps the old row beside an earlier build's resend until the host records the resend", () => { const hostItems = [...SEED_ROWS, userItem('held', 3, 'outbox copy')] const resent = restartRejected('held', 'outbox copy', 3) const resend = outboxEntry('resend', 'outbox copy') @@ -285,20 +308,63 @@ describe('one row per rejected message while the outbox still holds it', () => { rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent])) ).toEqual([ seedRow, + { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }, { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } ]) - // Its Retry was refused before the host recorded it: still the one outbox row. - const refused = { - ...resend, - lastAttemptAt: 60, - lastFailure: { kind: 'refused' as const, code: 'agent_session_journal_unreadable' as const } - } + const recorded = submission('resend', 'outbox copy', 60, { + dispatchState: 'pending', + providerItemId: null, + resolvedAt: null + }) expect( - rows(projectStructuredAgentSessionMessages(hostItems, [refused], [SEED, resent])) + rows( + projectStructuredAgentSessionMessages( + [...hostItems, userItem('resend', 4, 'outbox copy')], + [resend], + [SEED, resent, recorded] + ) + ) ).toEqual([ seedRow, - { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: true } + { id: agentJournalSubmissionKey('resend'), text: 'outbox copy', unsent: false } + ]) + }) +}) + +describe('a rejected message the queue holds', () => { + const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } + + // A hand-off the host rejected sends its draft back to the card list, which shows the text. + it('is not drawn when it was a queued draft handed off', () => { + const items = [...SEED_ROWS, userItem('handoff', 3, 'queued text')] + const submissions = [ + SEED, + { ...restartRejected('handoff', 'queued text', 3), queuedMessageId: 'card-1' } + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([seedRow]) + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size + ).toBe(0) + }) + + // A send kept across a restart comes back as a paused card under its own id. + it('is not drawn while a card holds it under its id, and is drawn once that card is gone', () => { + const items = [...SEED_ROWS, userItem('kept', 3, 'kept text')] + const submissions = [SEED, restartRejected('kept', 'kept text', 3)] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, ['kept']))).toEqual([ + seedRow + ]) + expect( + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [ + 'kept' + ]).size + ).toBe(0) + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('kept'), text: 'kept text', unsent: true } ]) }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 6b4a74194e25..1108d468739b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -31,7 +31,8 @@ function item(index: number): AgentJournalRenderItem { } describe('structured agent session message projection', () => { - it('keeps a refused local draft available through its outbox', () => { + // The host recorded it, so its row is the message from here; the outbox copy gives way. + it("draws a send the host rejected from the host's row, not the outbox copy", () => { const rejected = { ...submission(0), dispatchState: 'rejected' as const, providerItemId: null } const refusedItem = { ...item(0), itemId: agentJournalSubmissionKey(rejected.clientMessageId) } const draft = createStructuredAgentSessionOutboxEntry({ @@ -41,9 +42,13 @@ describe('structured agent session message projection', () => { attachments: [], queuedAt: 1 }) - expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toMatchObject( - [{ id: refusedItem.itemId, blocks: [{ text: 'An unsent draft' }] }] - ) + expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([ + expect.objectContaining({ + id: refusedItem.itemId, + blocks: [{ type: 'text', text: 'send 0' }], + unsent: true + }) + ]) }) it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => { 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 20eaf84da903..6cadfb152d3e 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 @@ -7,17 +7,18 @@ import { projectStructuredAgentSessionMessages as projectMessages } from '../../ import { projectStructuredQuestionMessages } from './structured-agent-question-projection' /** The desktop's transcript: a message the host accepted and then rejected stays where it was - * sent, as not sent. */ + * sent, as not sent, unless a queued card holds it. */ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + queuedMessageIds: readonly string[] = [] ) { return projectMessages( items, outbox, submissions, - { rejectedInPlace: true }, + { rejectedInPlace: true, queuedMessageIds }, projectStructuredQuestionMessages ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index e96e447e9eae..5a1343dcdb00 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -16,6 +16,7 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { stageStructuredAgentSessionOutboxEntryForSend, + structuredAgentSessionEntryRejectedByHost, structuredAgentSessionSendRequest, updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -58,10 +59,13 @@ export function readMountedStructuredAgentSessionOutbox( options: { recoverDispatching: boolean } ) => StructuredAgentSessionOutboxEntry[] ): StructuredAgentSessionOutboxEntry[] { - return read(sessionId, { recoverDispatching: false }).map((entry) => - entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) - ? { ...entry, state: 'unconfirmed' as const } - : entry + return read(sessionId, { recoverDispatching: false }).flatMap((entry) => + // The host's history holds one it recorded and rejected, whether or not its page is loaded. + structuredAgentSessionEntryRejectedByHost(entry) + ? [] + : entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) + ? [{ ...entry, state: 'unconfirmed' as const }] + : [entry] ) } 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 8f93a3ed5160..217d59ef2e1c 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 @@ -1,5 +1,5 @@ // The user's Retry on a stuck outbox entry: requeue it, rotating the operation -// id when the recorded one can only ever replay a settled rejection. +// id when the host has forgotten the recorded one. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { @@ -22,19 +22,10 @@ export function retryStructuredAgentSessionOutboxEntry(args: { const submission = submissions.find((candidate) => candidate.clientMessageId === clientMessageId) const outbox = getStructuredAgentSessionOutbox(sessionId) const current = outbox.find((entry) => entry.clientMessageId === clientMessageId) - // The host settled this id as rejected, and reusing it only replays that forever, so rotate the - // id for a safe resend. Read from the message itself, which outlives a restart, or from a - // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that - // settled the message already rotated it. An expired id is refused for good; its row told the - // user to check the chat first. - const recordedRejection = - current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' - if ( - current && - (recordedRejection || - submission?.dispatchState === 'rejected' || - structuredAgentSessionEntryIdExpired(current)) - ) { + // An expired id is refused for good, so only a new one sends it; its row told the user to check + // the chat first. A refusal that settled the message already rotated it, and one the host + // rejected after recording it has no Retry. + if (current && structuredAgentSessionEntryIdExpired(current)) { const rotated = outbox.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 150b520bcd32..0ff37b66540e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -259,7 +259,7 @@ describe('a message the host withdrew at a Stop', () => { expect(readNativeChatDraftCache(PANE)).toBe('hello\n\nhello') }) - it('keeps a message refused for any other reason on its Retry, and gives nothing back', async () => { + it("leaves a message rejected for any other reason to the host's row, and gives nothing back", async () => { answerSendsPending() const { result, rerender } = renderOutbox() const id = await sendToHost(result, 'hello') @@ -270,7 +270,7 @@ describe('a message the host withdrew at a Stop', () => { ] }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + await waitFor(() => expect(result.current.outbox).toEqual([])) expect(readNativeChatDraftCache(PANE)).toBe('') }) 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..73e210a2ed94 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it } from 'vitest' +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' + +afterEach(cleanup) + +const NONE = new Set() +const NO_CARDS: readonly string[] = [] +const EMPTY: never[] = [] + +function withdrawn(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' }, + submittedAt: 1, + resolvedAt: 2 + } +} + +// A Stop's withdrawn message draws no row, so a chat that has one rebuilds no notice per batch. +it('keeps the same notices across batches in a chat whose only rejection a Stop withdrew', () => { + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionDeliveryNotices({ + outbox: EMPTY, + submissions, + journalItems: EMPTY, + failedHere: NONE, + queuedMessageIds: NO_CARDS, + retry: () => {}, + agentName: 'Claude' + }), + { initialProps: { submissions: [withdrawn('stopped')] } } + ) + const first = result.current + + rerender({ submissions: [withdrawn('stopped')] }) + + expect(result.current).toBe(first) + expect(result.current.size).toBe(0) +}) 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 new file mode 100644 index 000000000000..a5e458d96fc4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-delivery-notices.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' +import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' + +const NO_SUBMISSIONS: readonly AgentJournalSubmission[] = [] + +/** The structured chat's delivery notices, by the message id each row renders under. */ +export function useStructuredAgentSessionDeliveryNotices(args: { + outbox: readonly StructuredAgentSessionOutboxEntry[] + submissions: readonly AgentJournalSubmission[] + journalItems: readonly AgentJournalRenderItem[] + failedHere: ReadonlySet + queuedMessageIds: readonly string[] + retry: (clientMessageId: string) => void + agentName: string +}): ReadonlyMap { + const { agentName, failedHere, outbox, queuedMessageIds, submissions } = args + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(args.retry) + useEffect(() => { + retryRef.current = args.retry + }) + const retry = useCallback((clientMessageId: string) => { + retryRef.current(clientMessageId) + }, []) + // Only a message shown as not sent reads the journal's rows (a withdrawn one draws nothing), so + // in a chat without one a new batch of them re-renders no row. + const hasRejected = + outbox.some((entry) => entry.state === 'rejected') || + submissions.some( + (submission) => submission.dispatchState === 'rejected' && !dispatchWasWithdrawn(submission) + ) + const rejectionRows = hasRejected ? submissions : NO_SUBMISSIONS + const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) + return useMemo( + () => + structuredAgentSessionDeliveryNotices( + outbox, + agentName, + retry, + rejectionRows, + startFailures, + failedHere, + queuedMessageIds + ), + [outbox, agentName, retry, rejectionRows, startFailures, failedHere, queuedMessageIds] + ) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx index 68e388de58c5..0732a5cceefd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx @@ -11,6 +11,7 @@ import { useStructuredAgentSessionMessages } from './use-structured-agent-sessio afterEach(cleanup) const EMPTY: never[] = [] +const NO_CARDS: readonly string[] = [] function tool(id: string, sequence: number): AgentJournalRenderItem { return { itemId: id, @@ -25,7 +26,8 @@ it('retains only unchanged item projections across updates, reorder, deletion, a const first = tool('first', 1) const second = tool('second', 2) const { result, rerender } = renderHook( - (items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY), + (items: AgentJournalRenderItem[]) => + useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS), { initialProps: [first, second] } ) const initial = result.current @@ -91,7 +93,7 @@ it('keeps optimistic sends and their settlement identical to uncached projection }: { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] - }) => useStructuredAgentSessionMessages(items, [entry], submissions), + }) => useStructuredAgentSessionMessages(items, [entry], submissions, NO_CARDS), { initialProps: { items: [tool('tool', 1)], submissions: [submission] } } ) for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) { @@ -106,7 +108,7 @@ it('keeps optimistic sends and their settlement identical to uncached projection it('does no transcript projection work on a status-only render', () => { const items = [tool('tool', 1)] const { result, rerender } = renderHook(() => - useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + useStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) ) const initial = result.current rerender() 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 f965c2d3eaa3..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 @@ -9,10 +9,11 @@ import { projectStructuredAgentSessionMessages } from './structured-agent-sessio export function useStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + queuedMessageIds: readonly string[] ) { return useMemo( - () => projectStructuredAgentSessionMessages(items, outbox, submissions), - [items, outbox, submissions] + () => projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds), + [items, outbox, submissions, queuedMessageIds] ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 8bf95a8a5115..ae6929f8ef26 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -88,7 +88,7 @@ describe('a send the host rejected because the agent never started', () => { localStorage.clear() }) - it('names the cause on the message and keeps it for Retry', async () => { + it('names the cause on the message until the journal row takes it over', async () => { mocks.call.mockImplementationOnce( async ( _target: unknown, @@ -108,7 +108,7 @@ describe('a send the host rejected because the agent never started', () => { act(() => expect(result.current.send('hello')).toBe(true)) await waitFor(() => expect(shownFailure(result.current.outbox[0])).toBe(REASON)) - // Settled as not delivered: it waits for Retry and holds no later message up. + // Settled as not delivered: it holds no later message up. expect(result.current.error).toBeNull() expect(result.current.outbox[0]?.state).toBe('rejected') }) @@ -151,7 +151,7 @@ describe('a send the host rejected because the agent never started', () => { ]) }) - it('keeps a message the host accepted and then could not deliver, with its reason and Retry', async () => { + it('leaves a message the host accepted and then could not deliver to its journal row', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." mocks.call.mockImplementation( async ( @@ -180,19 +180,11 @@ describe('a send the host rejected because the agent never started', () => { submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) - expect(shownFailure(result.current.outbox[0])).toBe(reason) + // The host's row shows it as not sent, with no Retry: nothing resends it. + await waitFor(() => expect(result.current.outbox).toEqual([])) expect(result.current.error).toBeNull() - - // Retry is a new message with the same text: a fresh id, sent once. - act(() => result.current.retry(id)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) - const retried: { - envelope: { clientOperationId: string } - body: { blocks: { text?: string }[] } - } = mocks.call.mock.calls[1]![2] - expect(retried.envelope.clientOperationId).not.toBe(id) - expect(retried.body.blocks[0]?.text).toBe('hello') + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + expect(mocks.call).toHaveBeenCalledOnce() }) it('says nothing when a Stop withdrew the message', async () => { @@ -233,7 +225,7 @@ describe('a send the host rejected because the agent never started', () => { expect(result.current.error).toBeNull() }) - it('reads a message rejected while the chat was closed as not sent, and sends past it', async () => { + it('leaves a message rejected while the chat was closed to its journal row, and sends past it', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." mocks.call.mockImplementation( async ( @@ -269,15 +261,13 @@ describe('a send the host rejected because the agent never started', () => { }) ) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) - expect(shownFailure(result.current.outbox[0])).toBe(reason) + await waitFor(() => expect(result.current.outbox).toEqual([])) act(() => expect(result.current.send('second')).toBe(true)) await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) - // After a restart nothing in memory remembers the rejection, and its journal row may be older - // than the loaded page: the message's own state is what says a resend needs a new id. - it('retries a message rejected before a restart under a new id', async () => { + // After a restart its journal row may be older than the loaded page; that row still holds it. + it('leaves a message rejected before a restart to its journal row, loaded or not', async () => { const rejected = createStructuredAgentSessionOutboxEntry({ clientMessageId: 'rejected-before-restart', sessionId: 'session-1', @@ -296,9 +286,6 @@ describe('a send the host rejected because the agent never started', () => { } } ]) - mocks.call.mockImplementation(async (_target, _method, params) => - acceptedResultFor(String(params.envelope.clientOperationId)) - ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ sessionId: 'session-1', @@ -307,16 +294,13 @@ describe('a send the host rejected because the agent never started', () => { submissions: [] }) ) - expect(result.current.outbox[0]?.state).toBe('rejected') - act(() => result.current.retry('rejected-before-restart')) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId - expect(sentId).not.toBe('rejected-before-restart') - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(result.current.outbox).toEqual([]) + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + expect(mocks.call).not.toHaveBeenCalled() }) - it('keeps the rejection when the journal settles the message before the send answers', async () => { + it('lets the journal settle the message when its rejection lands before the send answers', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." let answer: (value: unknown) => void = () => undefined mocks.call.mockImplementationOnce( @@ -345,11 +329,11 @@ describe('a send the host rejected because the agent never started', () => { rerender({ submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] }) - await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + await waitFor(() => expect(result.current.outbox).toEqual([])) + // The late `pending` answer must not bring it back. await act(async () => answer(pendingResultFor(id))) - expect(result.current.outbox[0]?.state).toBe('rejected') - expect(shownFailure(result.current.outbox[0])).toBe(reason) + expect(result.current.outbox).toEqual([]) expect(result.current.error).toBeNull() }) }) 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..bf25789d01b9 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 @@ -665,12 +665,7 @@ describe('useStructuredAgentSessionOutbox', () => { expect(retryParams?.retryUnknown).toBeUndefined() }) - it('rotates a history-rejected unknown head 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') + it('lets a history-rejected unknown head go to the journal so the queued tail advances', async () => { mocks.call .mockImplementationOnce(async (_target, _method, params) => { const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope @@ -682,11 +677,6 @@ describe('useStructuredAgentSessionOutbox', () => { .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({ @@ -717,51 +707,40 @@ describe('useStructuredAgentSessionOutbox', () => { ] }) - act(() => result.current.retry(firstId)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + // The host's row shows the first as not sent; nothing resends it. + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - const retryParams = mocks.call.mock.calls[1]?.[2] as - | { envelope: { clientOperationId: string } } - | undefined - expect(retryParams?.envelope.clientOperationId).not.toBe(firstId) + expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ body: { blocks: [{ text: 'second' }] } }) + expect(mocks.call.mock.calls[1]?.[2]).not.toMatchObject({ + envelope: { clientOperationId: firstId } + }) }) - it('rotates the id after a refused write and delivers the message exactly once', 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') - const writeFailed = (clientMessageId: string) => ({ + it('keeps a send its reply rejected without a Retry until the journal row takes it over', async () => { + const writeFailed = (clientMessageId: string): AgentJournalSubmission => ({ clientMessageId, fence: 1, payloadFingerprint: 'fingerprint', - dispatchState: 'rejected' as const, + dispatchState: 'rejected', providerItemId: null, reason: 'provider_write_failed: broken pipe', submittedAt: 10, resolvedAt: 10 }) - mocks.call - .mockImplementationOnce(async (_target, _method, params) => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 10 }, - value: { - clientMessageId: (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId, - submission: writeFailed( - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId - ) - } - })) - .mockImplementationOnce(async (_target, _method, params) => - acceptedResultFor( - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId, - 11 + mocks.call.mockImplementationOnce(async (_target, _method, params) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { + clientMessageId: (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId, + submission: writeFailed( + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId ) - ) - const { result } = renderHook( + } + })) + const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ sessionId: 'session-1', @@ -776,24 +755,13 @@ describe('useStructuredAgentSessionOutbox', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) const firstId = mocks.call.mock.calls[0]![2].envelope.clientOperationId as string - // A refused write is answered, not doubted: the entry parks with its rejection rather than - // under the "delivery is unconfirmed" banner. The disposition tests pin its words. + // Answered, not doubted: the entry draws the message, saying why, until the journal has it. await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected')) expect(result.current.outbox[0]?.state).toBe('rejected') - // Retry immediately, before the journal subscription can publish the rejected row. - act(() => result.current.retry(firstId)) + rerender({ submissions: [writeFailed(firstId)] }) await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - - // Exactly one further delivery, under a new id, and with no `retryUnknown`: - // this is a first delivery of a new message, so it cannot duplicate. - expect(mocks.call).toHaveBeenCalledTimes(2) - const retryParams = mocks.call.mock.calls[1]?.[2] as { - envelope: { clientOperationId: string } - retryUnknown?: true - } - expect(retryParams.envelope.clientOperationId).not.toBe(firstId) - expect(retryParams.retryUnknown).toBeUndefined() + expect(mocks.call).toHaveBeenCalledOnce() }) it('loads the new session outbox when a pane switches sessions', async () => { 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 96ad805560d9..7a212f366dfa 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 @@ -166,7 +166,8 @@ export function useStructuredAgentSession(args: { const messages = useStructuredAgentSessionMessages( transportState.journalItems, transcriptOutbox, - transportState.submissions + transportState.submissions, + queuedMessageIds ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, @@ -216,6 +217,8 @@ export function useStructuredAgentSession(args: { failedHere: outboxController.failedHere, /** The journal's rows for sent messages, which carry a rejected message's whole fact. */ submissions: transportState.submissions, + /** The host's queued cards, which hold their own rejected hand-offs. */ + queuedMessageIds, // A message typed during a command queues behind it on the host. send: (...input: Parameters) => // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 456bd20ed9fb..cc0d0240b869 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -1,15 +1,9 @@ -import type { - AgentJournalMessageItem, - AgentJournalRenderItem, - AgentJournalSubmission -} from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { agentJournalSubmissionKey } from './agent-session-journal-item-key' -import { agentJournalItemPosition } from './agent-session-journal-position' import { isQueuedAgentJournalSubmission } from './agent-session-queued-submission' import { collapseProviderRetryRuns } from './native-chat-provider-retry-runs' import type { NativeChatMessage } from './native-chat-types' import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' -import { structuredAgentSessionSendBodyFingerprint } from './structured-agent-session-mutation' 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' @@ -19,46 +13,49 @@ export type StructuredAgentSessionMessageProjectionOptions = { /** Draw a message the host accepted and then rejected where the host recorded it, as not sent. * Off for a client that hands such a message back to its composer instead. */ rejectedInPlace: boolean -} - -const outboxBodyFingerprints = new WeakMap() - -function outboxBodyFingerprint(entry: StructuredAgentSessionOutboxEntry): string { - let fingerprint = outboxBodyFingerprints.get(entry.body) - if (fingerprint === undefined) { - fingerprint = structuredAgentSessionSendBodyFingerprint(entry.sessionId, entry.body) - outboxBodyFingerprints.set(entry.body, fingerprint) - } - return fingerprint + /** The queue's live cards: a rejected message one of them holds is drawn there, not here. */ + queuedMessageIds?: readonly string[] } /** - * The rejected submissions the host's history shows in place, by item id. A withdrawn one went - * back to its sender; one the outbox still draws is drawn once, by the outbox; and one with a later - * copy of the same body is superseded by it — earlier builds resent a rejected message under a new - * id, so without this an update would surface every resent copy. + * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` + * in submission order, as the client keeps them. A withdrawn one went back to its sender, and one + * the queue holds (a draft's hand-off, or a card under its id) is drawn as its card. */ -function rejectedShownInPlace( +export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], - outbox: readonly StructuredAgentSessionOutboxEntry[] + queuedMessageIds: readonly string[] ): Set { - const shown = new Set() - const outboxIds = new Set(outbox.map((entry) => entry.clientMessageId)) - // Submissions arrive ordered by submission time. A withdrawn copy is hidden too, so it + const cards = new Set(queuedMessageIds) + // Each body's copies, as positions in submission order. A withdrawn one is hidden too, so it // supersedes nothing. - const lastCopy = new Map() + const copies = new Map() for (const [index, submission] of submissions.entries()) { if (!dispatchWasWithdrawn(submission)) { - lastCopy.set(submission.payloadFingerprint, index) + const copy = { index, submittedAt: submission.submittedAt } + const same = copies.get(submission.payloadFingerprint) + if (same) { + same.push(copy) + } else { + copies.set(submission.payloadFingerprint, [copy]) + } } } + const shown = new Set() for (const [index, submission] of submissions.entries()) { + const { resolvedAt } = submission if ( submission.dispatchState !== 'rejected' || dispatchWasWithdrawn(submission) || - outboxIds.has(submission.clientMessageId) || - (lastCopy.get(submission.payloadFingerprint) ?? index) > index || - outbox.some((entry) => outboxBodyFingerprint(entry) === submission.payloadFingerprint) + submission.queuedMessageId !== undefined || + cards.has(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 + // was known counts, so a repeat sent before it is kept. + (resolvedAt !== null && + (copies.get(submission.payloadFingerprint) ?? []).some( + (copy) => copy.index > index && copy.submittedAt >= resolvedAt + )) ) { continue } @@ -82,17 +79,14 @@ export function projectStructuredAgentSessionMessages( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const inPlace = options.rejectedInPlace - ? rejectedShownInPlace(submissions, optimistic) + ? structuredAgentSessionRejectedShownInPlace(submissions, options.queuedMessageIds ?? []) : new Set() const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] - const refused = new Map() for (const item of items) { if (inPlace.has(item.itemId)) { unsentItems.push(item) - } else if (rejected.has(item.itemId)) { - refused.set(item.itemId, item) - } else { + } else if (!rejected.has(item.itemId)) { visibleItems.push(item) } } @@ -121,21 +115,15 @@ export function projectStructuredAgentSessionMessages( ...projectItems(unsentItems).map((message) => ({ ...message, unsent: true as const })), ...optimistic .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) - .map((entry): NativeChatMessage => { - const id = agentJournalSubmissionKey(entry.clientMessageId) - const recorded = refused.get(id) - return { - id, - role: 'user', - source: 'transcript', - timestamp: entry.queuedAt, - blocks: entry.body.blocks, - ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) - ? { unsent: true as const } - : {}), - // A send the journal recorded before refusing it keeps its place there. - ...(recorded ? { journalPosition: agentJournalItemPosition(recorded) } : {}) - } - }) + .map((entry): NativeChatMessage => ({ + id: agentJournalSubmissionKey(entry.clientMessageId), + role: 'user', + source: 'transcript', + timestamp: entry.queuedAt, + blocks: entry.body.blocks, + ...(entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) + ? { unsent: true as const } + : {}) + })) ] } diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index f3d9b848c697..4c25de1a1bdc 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,4 +1,3 @@ -import type { AgentJournalMessageItem } from './agent-session-journal-types' import { sha256 } from './sha256' function canonicalize(value: unknown): string { @@ -27,19 +26,6 @@ export function structuredAgentSessionPayloadFingerprint(input: { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') } -/** The body-only hash a send's submission row stores: what the reducer recomputes to alias a - * provider echo onto its submission, so it must never include control fields. */ -export function structuredAgentSessionSendBodyFingerprint( - sessionId: string, - body: AgentJournalMessageItem -): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - export function structuredAgentSessionDomainFingerprint(input: { domain: string sessionId: string diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index d7ee2f59d3dd..54efaa1326be 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -12,11 +12,11 @@ import { } from './agent-session-refusal-retry' 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' -/** `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. */ +/** `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 only for the + * journal, whose row then shows it (`structuredAgentSessionEntryRejectedByHost`). */ export type StructuredAgentSessionOutboxState = | 'queued' | 'dispatching' @@ -174,6 +174,14 @@ export function structuredAgentSessionEntryIdExpired( ) } +/** The host recorded this send and then rejected it: its journal row is the message from here. The + * entry offers no Retry and draws it only until the journal carries it; a remount drops it. */ +export function structuredAgentSessionEntryRejectedByHost( + entry: StructuredAgentSessionOutboxEntry +): boolean { + return entry.state === 'rejected' && entry.lastFailure?.kind === 'rejected' +} + export function requeueStructuredAgentSessionSendRefusal( entry: StructuredAgentSessionOutboxEntry, refusal: AgentSessionWriteRefusal, @@ -213,13 +221,9 @@ export function reconcileStructuredAgentSessionOutbox( const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) return entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - if (submission?.dispatchState === 'accepted') { - return [] - } - if ( - submission?.dispatchState === 'rejected' && - classifyDispatchRejection(submission).category === 'withdrawn' - ) { + // Settled by the host: its history shows one delivered or rejected; a withdrawn one goes back + // to the composer. + if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'rejected') { return [] } if (submission?.dispatchState === 'pending') { @@ -230,21 +234,6 @@ export function reconcileStructuredAgentSessionOutbox( const { lastFailure: _landed, ...landed } = entry return [{ ...landed, state: 'dispatching' as const }] } - // Accepted, then not delivered — the agent never started, or its start was refused. The text - // and why stay here for the user's Retry, and nothing queues behind it. `unconfirmed` is how a - // remount reads an entry it left dispatching; the journal has since answered it. - if ( - submission?.dispatchState === 'rejected' && - (entry.state === 'dispatching' || entry.state === 'unconfirmed') - ) { - return [ - { - ...entry, - state: 'rejected' as const, - lastFailure: structuredAgentSessionRejectedFailure(submission) - } - ] - } if ( submission?.dispatchState === 'unknown' && entry.retryAfterUnknownSubmittedAt !== -1 && diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index f717e54f2d43..73b65d7f9fd2 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -76,8 +76,8 @@ function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEnt * the outbox. Nothing is lost from the conversation: the durable submission row * already renders the message. * - * A `rejected` submission takes the other path — the message provably did not - * happen, so Retry rotates the id and sends it as a genuinely new message. + * A `rejected` submission is the host's to show, with no Retry: the reconcile + * drops its entry once the journal carries it. */ function refusedRedelivery( entry: StructuredAgentSessionOutboxEntry, @@ -267,6 +267,8 @@ export function disposeStructuredAgentSessionSendResult( error: null } } + // Recorded, so the journal's row shows it once it arrives; until then the entry draws it, saying + // why and offering no Retry. if (submission.dispatchState === 'rejected') { return { entries: replaceEntryState( From 39d7cf2b503e6a35987b79a6f95928c0c53b6600 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:13:07 -0700 Subject: [PATCH 05/19] test(native-chat): keep one row when copies of a rejected message share an instant --- ...n-message-projection.rejected-in-place.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 4c4fb89ca53c..eb91ec0a039b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -210,6 +210,21 @@ describe('a message the host accepted and then rejected, on the desktop', () => ]) }) + // The host re-delivers its own message under new ids; however close their times, one row stays. + it('keeps the last of several copies rejected in the same instant', () => { + const items = [...SEED_ROWS, userItem('a', 3, 'pointer'), userItem('b', 4, 'pointer')] + const submissions = [ + SEED, + restartRejected('a', 'pointer', 5), + restartRejected('b', 'pointer', 5) + ] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, + { id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true } + ]) + }) + it('stays when the same body was only sent before it, or by a copy a Stop withdrew', () => { const items = [ ...SEED_ROWS, From cfa2644c442b90b835de630a2448cd0ff9e7131b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:15:28 -0700 Subject: [PATCH 06/19] refactor(native-chat): let the host's notice replace the outbox's under the same id --- .../native-chat/structured-agent-session-delivery-notices.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 f0e35836cf49..b56ad0a928eb 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 @@ -165,10 +165,6 @@ export function structuredAgentSessionDeliveryNotices( ) const notices = new Map() for (const [index, entry] of outbox.entries()) { - // The journal settled it, and the reconcile is about to drop it. - if (rejected.has(entry.clientMessageId)) { - continue - } if ( entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry) || @@ -185,6 +181,7 @@ export function structuredAgentSessionDeliveryNotices( ) } } + // After the outbox's: the host's row is the message, whatever the outbox still holds under its id. const shown = structuredAgentSessionRejectedShownInPlace(submissions, queuedMessageIds) for (const submission of rejected.values()) { const id = agentJournalSubmissionKey(submission.clientMessageId) From 2252ced879b745e2ba13aeddc36bc880ab0940bd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:23:43 -0700 Subject: [PATCH 07/19] test(native-chat): pass the queued card ids in the tool-stream cost transcript --- .../native-chat/NativeChatMessageList.tool-stream-cost.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx index a77d2907b6a2..d8a9379ca190 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tool-stream-cost.test.tsx @@ -32,7 +32,7 @@ afterEach(cleanup) const EMPTY: never[] = [] const loadEarlier = () => Promise.resolve('exhausted' as const) function Transcript({ items }: { items: AgentJournalRenderItem[] }) { - const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY) + const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY, EMPTY) const session: NativeChatLiveSession = { messages, status: 'working', From f5d29b5e60f341756644cdaf46dabccad3dddd30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:47:52 -0700 Subject: [PATCH 08/19] fix(native-chat): keep the host's record as what lets a rejected message go - the outbox no longer drops a host-rejected message when a chat opens; the reconcile lets it go once the journal's submissions say it was rejected, and that drop is written to storage, so nothing reads as still owed - a message the host rejected while the chat watched waits for its journal row with no Retry; one read back from storage with no row loaded keeps its Retry under a new id, since the host may have lost it - the delivery notices keep the same map and notice objects across a batch that words every row the same, so a submission batch re-renders no row - a rejected command such as /compact stays hidden: its own reply reports it - the desktop transcript requires the queued card ids, with a controller-level test that a card holding a rejected message keeps its row hidden --- ...atMessageList.provider-retry-runs.test.tsx | 4 +- ...iveChatMessageList.unsent-message.test.tsx | 6 +- .../native-chat-rail-outline-parity.test.ts | 4 +- ...red-agent-session-delivery-notices.test.ts | 25 +++- ...ructured-agent-session-delivery-notices.ts | 8 +- ...ssage-projection.rejected-in-place.test.ts | 57 ++++++--- ...d-agent-session-message-projection.test.ts | 15 ++- ...ctured-agent-session-message-projection.ts | 3 +- ...tructured-agent-session-outbox-dispatch.ts | 12 +- .../structured-agent-session-outbox-retry.ts | 19 ++- ...red-agent-session-transcript-order.test.ts | 4 +- ...ed-agent-session-delivery-notices.test.tsx | 70 +++++++++++ ...ructured-agent-session-delivery-notices.ts | 33 ++++- ...structured-agent-session-messages.test.tsx | 10 +- ...nt-session-outbox-rejection-cause.test.tsx | 90 ++++++++++---- ...ent-session-outbox.rejected-reply.test.tsx | 4 +- ...tured-agent-session.rejected-card.test.tsx | 116 ++++++++++++++++++ ...ctured-agent-session-message-projection.ts | 3 +- src/shared/structured-agent-session-outbox.ts | 7 +- 19 files changed, 411 insertions(+), 79 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx index eb71e3e6b541..db59afdb19db 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-retry-runs.test.tsx @@ -10,6 +10,8 @@ import { NativeChatMessageList } from './NativeChatMessageList' import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + let restoreViewport = (): void => {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -42,7 +44,7 @@ function transcript(items: AgentJournalRenderItem[]) { return ( {} beforeAll(() => { restoreViewport = installNativeChatMessageListTestViewport() @@ -137,7 +139,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo return ( { - // Recorded by the host, so sending it again is a new message: no Retry until the journal row - // takes it over. + // Rejected while the chat watched, so the host has them: no Retry, the journal row takes over. it('gives two messages the host rejected each their own reason and no Retry', () => { const retry = vi.fn() const notices = structuredAgentSessionDeliveryNotices( @@ -79,7 +78,7 @@ describe('the notice on each message that did not go through', () => { retry, [], [], - NOT_FAILED_HERE + new Set(['first', 'second']) ) expect([...notices.keys()]).toEqual([ @@ -96,6 +95,26 @@ describe('the notice on each message that did not go through', () => { expect(notices.get(agentJournalSubmissionKey('second'))?.onRetry).toBeUndefined() }) + // Read back from storage with no row loaded: the host may have lost it, so it keeps its Retry. + it('keeps the Retry on a message the host rejected before this chat opened', () => { + const retry = vi.fn() + const notice = structuredAgentSessionDeliveryNotices( + [ + entry('earlier', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Claude messages support at most 20 images' } + }) + ], + 'Claude', + retry, + [], + [], + NOT_FAILED_HERE + ).get(agentJournalSubmissionKey('earlier')) + notice?.onRetry?.() + expect(retry).toHaveBeenCalledExactlyOnceWith('earlier') + }) + it('chooses the words from the saved refusal on a refused message', () => { expect( texts([ 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 b56ad0a928eb..9ddb23707a7d 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 @@ -10,7 +10,7 @@ // A message the host recorded and then rejected is drawn from the host's history, worded from the // journal's own fact, with no Retry: sending it again is a new message. A rejection that is a // failed start's, the fact its loaded row states, says only that it was not sent: the row already -// says why. Until the journal carries it, the outbox draws it from its smaller copy of that fact. +// says why. Until the journal's submissions say so, the outbox draws it from its smaller copy. import { readAgentSessionFailureFact, @@ -170,10 +170,12 @@ export function structuredAgentSessionDeliveryNotices( structuredAgentSessionEntryHeldForRetry(entry) || entry.clientMessageId === held ) { - // Its own Retry is the step, so the words leave out sending again. + // Its own Retry is the step, so the words leave out sending again. One the host rejected + // while this chat watched waits for its journal row instead: the host has it. Read back from + // storage with no row loaded, the host may have lost it, so it keeps its Retry. const retryControl = (stalledFrom === -1 || index <= stalledFrom) && - !structuredAgentSessionEntryRejectedByHost(entry) + !(structuredAgentSessionEntryRejectedByHost(entry) && failedHere.has(entry.clientMessageId)) const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere) notices.set( agentJournalSubmissionKey(entry.clientMessageId), diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index eb91ec0a039b..f91814597dfd 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -19,6 +19,8 @@ import { import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + const SESSION = 'session-1' function body(text: string) { @@ -128,7 +130,8 @@ describe('a message the host accepted and then rejected, on the desktop', () => const messages = projectStructuredAgentSessionMessages( items, [], - [SEED, restartRejected('lost', 'fix the parser', 3)] + [SEED, restartRejected('lost', 'fix the parser', 3)], + NO_CARDS ) expect(rows(messages)).toEqual([ @@ -187,7 +190,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => submission('resent', 'retry me', 4) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false } ]) @@ -202,7 +205,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => submission('again', 'continue', 4) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('again'), text: 'continue', unsent: false }, // Listed after the delivered rows; its journal position keeps its place. @@ -219,7 +222,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => restartRejected('b', 'pointer', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true } ]) @@ -239,18 +242,33 @@ describe('a message the host accepted and then rejected, on the desktop', () => withdrawn('stopped', 'again', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('first'), text: 'again', unsent: false }, { id: agentJournalSubmissionKey('failed'), text: 'again', unsent: true } ]) }) + // Its own reply reports the rejection, in the composer, as a command's. + it('is not drawn when it was a command such as /compact', () => { + const compact = { + ...userItem('compact', 3, '/compact'), + body: { ...body('/compact'), command: { name: 'compact' } } + } + const submissions = [SEED, restartRejected('compact', '/compact', 3)] + + expect( + rows( + projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS) + ) + ).toEqual([{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }]) + }) + it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } ]) expect( @@ -276,7 +294,7 @@ describe("one row per rejected message, the host's once it records the rejection const seedRow = { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } it('the reply first: the outbox draws it, saying why, with no Retry', () => { - const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED]) + const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED], NO_CARDS) expect(rows(messages)).toEqual([seedRow, heldRow]) const notice = structuredAgentSessionDeliveryNotices( [held], @@ -284,7 +302,7 @@ describe("one row per rejected message, the host's once it records the rejection () => {}, [SEED], [], - new Set() + new Set(['held']) ).get(heldRow.id) expect(notice?.text).toBe('Orca restarted before this message was sent.') expect(notice?.onRetry).toBeUndefined() @@ -292,13 +310,17 @@ describe("one row per rejected message, the host's once it records the rejection it("the journal first, or next: the host's row replaces the outbox copy at once", () => { const dispatching = { ...held, state: 'dispatching' as const, lastFailure: undefined } - expect(rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED]))).toEqual([ - seedRow, - { ...heldRow, unsent: false } - ]) + expect( + rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED], NO_CARDS)) + ).toEqual([seedRow, { ...heldRow, unsent: false }]) for (const entry of [dispatching, held]) { // Before the reconcile drops the entry, the host's row is already the one row. - const messages = projectStructuredAgentSessionMessages(items, [entry], [SEED, rejected]) + const messages = projectStructuredAgentSessionMessages( + items, + [entry], + [SEED, rejected], + NO_CARDS + ) expect(rows(messages)).toEqual([seedRow, hostRow]) expect(messages.at(-1)?.journalPosition).toEqual({ sequence: 3, index: 0 }) const notices = structuredAgentSessionDeliveryNotices( @@ -320,7 +342,7 @@ describe("one row per rejected message, the host's once it records the rejection const resent = restartRejected('held', 'outbox copy', 3) const resend = outboxEntry('resend', 'outbox copy') expect( - rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent])) + rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent], NO_CARDS)) ).toEqual([ seedRow, { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }, @@ -337,7 +359,8 @@ describe("one row per rejected message, the host's once it records the rejection projectStructuredAgentSessionMessages( [...hostItems, userItem('resend', 4, 'outbox copy')], [resend], - [SEED, resent, recorded] + [SEED, resent, recorded], + NO_CARDS ) ) ).toEqual([ @@ -358,7 +381,9 @@ describe('a rejected message the queue holds', () => { { ...restartRejected('handoff', 'queued text', 3), queuedMessageId: 'card-1' } ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([seedRow]) + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + seedRow + ]) expect( structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts index 1108d468739b..bd99e8f8cbbf 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -7,6 +7,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + function submission(index: number): AgentJournalSubmission { return { clientMessageId: `client-${index}`, @@ -42,7 +44,9 @@ describe('structured agent session message projection', () => { attachments: [], queuedAt: 1 }) - expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([ + expect( + projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected], NO_CARDS) + ).toEqual([ expect.objectContaining({ id: refusedItem.itemId, blocks: [{ type: 'text', text: 'send 0' }], @@ -64,7 +68,8 @@ describe('structured agent session message projection', () => { const messages = projectStructuredAgentSessionMessages( Array.from({ length: sendCount }, (_, index) => item(index)), outbox, - Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)) + Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)), + NO_CARDS ) expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount) @@ -99,8 +104,8 @@ describe('structured agent session message projection', () => { resolvedAt: null } - const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending]) - const optimistic = projectStructuredAgentSessionMessages([], outbox, []) + const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending], NO_CARDS) + const optimistic = projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS) expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) expect(messages.map((message) => message.id)).toEqual([walItem.itemId]) @@ -118,7 +123,7 @@ describe('structured agent session message projection', () => { }) ] - expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([ + expect(projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)).toMatchObject([ { id: agentJournalSubmissionKey('client-pending'), role: 'user' } ]) }) 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 6cadfb152d3e..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 @@ -12,7 +12,8 @@ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[] = [] + /** The queue's live cards; required, since a rejected message a card holds must not draw twice. */ + queuedMessageIds: readonly string[] ) { return projectMessages( items, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 5a1343dcdb00..e96e447e9eae 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -16,7 +16,6 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { stageStructuredAgentSessionOutboxEntryForSend, - structuredAgentSessionEntryRejectedByHost, structuredAgentSessionSendRequest, updateStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -59,13 +58,10 @@ export function readMountedStructuredAgentSessionOutbox( options: { recoverDispatching: boolean } ) => StructuredAgentSessionOutboxEntry[] ): StructuredAgentSessionOutboxEntry[] { - return read(sessionId, { recoverDispatching: false }).flatMap((entry) => - // The host's history holds one it recorded and rejected, whether or not its page is loaded. - structuredAgentSessionEntryRejectedByHost(entry) - ? [] - : entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) - ? [{ ...entry, state: 'unconfirmed' as const }] - : [entry] + return read(sessionId, { recoverDispatching: false }).map((entry) => + entry.state === 'dispatching' && !hasInFlightLaunchDispatch(entry, fence) + ? { ...entry, state: 'unconfirmed' as const } + : entry ) } 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 217d59ef2e1c..8f93a3ed5160 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 @@ -1,5 +1,5 @@ // The user's Retry on a stuck outbox entry: requeue it, rotating the operation -// id when the host has forgotten the recorded one. +// id when the recorded one can only ever replay a settled rejection. import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { @@ -22,10 +22,19 @@ export function retryStructuredAgentSessionOutboxEntry(args: { const submission = submissions.find((candidate) => candidate.clientMessageId === clientMessageId) const outbox = getStructuredAgentSessionOutbox(sessionId) const current = outbox.find((entry) => entry.clientMessageId === clientMessageId) - // An expired id is refused for good, so only a new one sends it; its row told the user to check - // the chat first. A refusal that settled the message already rotated it, and one the host - // rejected after recording it has no Retry. - if (current && structuredAgentSessionEntryIdExpired(current)) { + // The host settled this id as rejected, and reusing it only replays that forever, so rotate the + // id for a safe resend. Read from the message itself, which outlives a restart, or from a + // reconciliation that settled an earlier unknown before the outbox caught up. A refusal that + // settled the message already rotated it. An expired id is refused for good; its row told the + // user to check the chat first. + const recordedRejection = + current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' + if ( + current && + (recordedRejection || + submission?.dispatchState === 'rejected' || + structuredAgentSessionEntryIdExpired(current)) + ) { const rotated = outbox.map((entry) => entry.clientMessageId === clientMessageId ? { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts index aa774f7fbf3d..3b12ed7b3dcb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts @@ -13,6 +13,8 @@ import { import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' +const NO_CARDS: readonly string[] = [] + function journalItem( itemId: string, sequence: number, @@ -43,7 +45,7 @@ function drawn( submissions: AgentJournalSubmission[] = [] ): string[] { return createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, outbox, submissions) + projectStructuredAgentSessionMessages(items, outbox, submissions, NO_CARDS) ).conversation.map(({ id }) => id) } 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 73e210a2ed94..28ef88564b90 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 @@ -12,6 +12,36 @@ const NONE = new Set() const NO_CARDS: readonly string[] = [] const EMPTY: never[] = [] +function rejected( + clientMessageId: string, + kind: 'hostRestarted' | 'notDelivered' +): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'rejected', + providerItemId: null, + reason: null, + rejection: { kind }, + submittedAt: 1, + resolvedAt: 2 + } +} + +function accepted(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: 3, + resolvedAt: 4 + } +} + function withdrawn(clientMessageId: string): AgentJournalSubmission { return { clientMessageId, @@ -48,3 +78,43 @@ it('keeps the same notices across batches in a chat whose only rejection a Stop expect(result.current).toBe(first) expect(result.current.size).toBe(0) }) + +function renderNotices(submissions: readonly AgentJournalSubmission[]) { + return renderHook( + ({ submissions: current }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionDeliveryNotices({ + outbox: EMPTY, + submissions: current, + journalItems: EMPTY, + failedHere: NONE, + queuedMessageIds: NO_CARDS, + retry: () => {}, + agentName: 'Claude' + }), + { initialProps: { submissions } } + ) +} + +// A batch for another message rebuilds the journal's rows; the rows already marked not sent keep +// the same notices, so no row wrapper re-renders. +it('keeps the same notices when a batch leaves every not-sent message as it was', () => { + const { result, rerender } = renderNotices([rejected('lost', 'hostRestarted')]) + const first = result.current + expect(first.size).toBe(1) + + rerender({ submissions: [rejected('lost', 'hostRestarted'), accepted('next')] }) + + expect(result.current).toBe(first) +}) + +it('keeps the notice of a row that did not change when another one does', () => { + const { result, rerender } = renderNotices([rejected('lost', 'hostRestarted')]) + const lost = result.current.get('orca:lost') + + rerender({ + submissions: [rejected('lost', 'hostRestarted'), rejected('other', 'notDelivered')] + }) + + expect(result.current.size).toBe(2) + expect(result.current.get('orca:lost')).toBe(lost) +}) 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 a5e458d96fc4..ddc76998ec03 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 @@ -39,7 +39,7 @@ export function useStructuredAgentSessionDeliveryNotices(args: { ) const rejectionRows = hasRejected ? submissions : NO_SUBMISSIONS const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) - return useMemo( + const notices = useMemo( () => structuredAgentSessionDeliveryNotices( outbox, @@ -52,4 +52,35 @@ export function useStructuredAgentSessionDeliveryNotices(args: { ), [outbox, agentName, retry, rejectionRows, startFailures, failedHere, queuedMessageIds] ) + // A submission batch rebuilds the map; one that says the same keeps the old, so no row re-renders. + const previousRef = useRef(notices) + const stable = sameNoticesKept(previousRef.current, notices) + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} + +/** `next`, reusing each notice `previous` words the same way, and `previous` itself when all are. */ +function sameNoticesKept( + previous: ReadonlyMap, + next: ReadonlyMap +): ReadonlyMap { + if (previous === next) { + return next + } + let allKept = previous.size === next.size + const kept = new Map() + for (const [id, notice] of next) { + const before = previous.get(id) + // Each Retry calls the stable `retry` with its own id, so one under the same key is the same. + const same = + before !== undefined && + before.text === notice.text && + (before.onRetry === undefined) === (notice.onRetry === undefined) && + (before.onDismiss === undefined) === (notice.onDismiss === undefined) + allKept &&= same + kept.set(id, same ? before : notice) + } + return allKept ? previous : kept } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx index 0732a5cceefd..419e6a6cae9b 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-messages.test.tsx @@ -52,7 +52,9 @@ it('retains only unchanged item projections across updates, reorder, deletion, a [structuredClone(completed)] ]) { rerender(items) - expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY)) + expect(result.current).toEqual( + projectStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) + ) expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1]) } const replacement = { @@ -64,7 +66,9 @@ it('retains only unchanged item projections across updates, reorder, deletion, a } } rerender([replacement]) - expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY)) + expect(result.current).toEqual( + projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY, NO_CARDS) + ) expect(result.current[0]).not.toBe(initial[0]) }) @@ -100,7 +104,7 @@ it('keeps optimistic sends and their settlement identical to uncached projection const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] } rerender(props) expect(result.current).toEqual( - projectStructuredAgentSessionMessages(props.items, [entry], props.submissions) + projectStructuredAgentSessionMessages(props.items, [entry], props.submissions, NO_CARDS) ) } }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index ae6929f8ef26..987bdcc3d7aa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -22,7 +22,11 @@ import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' -import { writeOutbox } from './structured-agent-session-outbox-storage' +import { + hasUndeliveredStructuredAgentSessionOutbox, + readOutbox, + writeOutbox +} from './structured-agent-session-outbox-storage' // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -266,26 +270,13 @@ describe('a send the host rejected because the agent never started', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) - // After a restart its journal row may be older than the loaded page; that row still holds it. - it('leaves a message rejected before a restart to its journal row, loaded or not', async () => { - const rejected = createStructuredAgentSessionOutboxEntry({ - clientMessageId: 'rejected-before-restart', - sessionId: 'session-1', - text: 'first', - attachments: [], - queuedAt: 1 - }) - writeOutbox('session-1', [ - { - ...rejected, - state: 'rejected', - lastFailure: { - kind: 'rejected', - reason: 'The provider did not accept this message.', - rejection: { kind: 'providerRejected' } - } - } - ]) + // After a restart nothing in memory remembers the rejection, and its journal row may be older + // than the loaded page, or lost to a repair: the message keeps its Retry, which needs a new id. + it('retries a message rejected before a restart under a new id while its row is not loaded', async () => { + writeOutbox('session-1', [rejectedBeforeRestart()]) + mocks.call.mockImplementation(async (_target, _method, params) => + acceptedResultFor(String(params.envelope.clientOperationId)) + ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ sessionId: 'session-1', @@ -294,9 +285,44 @@ describe('a send the host rejected because the agent never started', () => { submissions: [] }) ) + expect(result.current.outbox[0]?.state).toBe('rejected') - expect(result.current.outbox).toEqual([]) - await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + act(() => result.current.retry('rejected-before-restart')) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId + expect(sentId).not.toBe('rejected-before-restart') + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + }) + + // The host's own record is what lets it go, and storage forgets it too: nothing is owed. + it('drops a message rejected before a restart once the journal says so, from storage too', async () => { + writeOutbox('session-1', [rejectedBeforeRestart()]) + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + expect(result.current.outbox).toHaveLength(1) + + rerender({ + submissions: [ + { + ...pendingResultFor('rejected-before-restart').value.submission, + dispatchState: 'rejected', + reason: 'The provider did not accept this message.', + resolvedAt: 2 + } + ] + }) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readOutbox('session-1')).toEqual([]) + expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false) expect(mocks.call).not.toHaveBeenCalled() }) @@ -340,6 +366,24 @@ describe('a send the host rejected because the agent never started', () => { const NO_SUBMISSIONS: AgentJournalSubmission[] = [] +function rejectedBeforeRestart(): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'rejected-before-restart', + sessionId: 'session-1', + text: 'first', + attachments: [], + queuedAt: 1 + }), + state: 'rejected', + lastFailure: { + kind: 'rejected', + reason: 'The provider did not accept this message.', + rejection: { kind: 'providerRejected' } + } + } +} + function pendingResultFor(clientMessageId: string) { const submission: AgentJournalSubmission = { clientMessageId, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx index 063c56873519..6c1ffac40856 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx @@ -29,6 +29,8 @@ import { import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +const NO_CARDS: readonly string[] = [] + afterEach(cleanup) beforeEach(() => { @@ -86,7 +88,7 @@ it('draws a send its reply rejected in place once, and leaves the composer empty body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'steer this way' }] } } const users = (outbox: typeof result.current.outbox) => - projectStructuredAgentSessionMessages([hostItem], outbox, [submission]) + projectStructuredAgentSessionMessages([hostItem], outbox, [submission], NO_CARDS) .filter((message) => message.role === 'user') .map((message) => ({ id: message.id, unsent: message.unsent })) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx new file mode 100644 index 000000000000..b37350cb39dd --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rejected-card.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment happy-dom + +// The session controller hands the queue's live cards to the transcript: a rejected message one of +// them holds under its own id is drawn as that card, never also as a not-sent row. + +import { renderHook } from '@testing-library/react' +import { beforeEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' + +let queuedMessages: AgentSessionQueuedMessage[] = [] + +const KEPT_BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'kept text' }] +} + +const KEPT_ITEM: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('kept'), + revision: 1, + sequence: 1, + observedAt: 1, + body: KEPT_BODY +} + +const KEPT_SUBMISSION: AgentJournalSubmission = { + clientMessageId: 'kept', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2 +} + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(async () => null), + supportsStructuredAgentSessionPromptCancel: vi.fn(async () => false) +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence: 3, + items: [KEPT_ITEM], + submissions: [KEPT_SUBMISSION], + status: 'ready', + error: null, + hasOlder: false, + queuedMessages + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: () => 'operation-1', + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + error: null, + send: vi.fn(), + retry: vi.fn(), + withdrawUnsent: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +function card(messageId: string): AgentSessionQueuedMessage { + return { + messageId, + position: 1, + body: KEPT_BODY, + state: 'waiting' + } +} + +function keptRows(): { id: string; unsent?: true }[] { + const { result, unmount } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + agent: 'claude', + target: { kind: 'local' }, + isVisible: true, + composerScopeKey: 'scope-1' + }) + ) + const rows = result.current.messages + .filter((message) => message.id === KEPT_ITEM.itemId) + .map(({ id, unsent }) => ({ id, ...(unsent ? { unsent } : {}) })) + unmount() + return rows +} + +beforeEach(() => { + queuedMessages = [] +}) + +it('draws no row for a rejected message while a card holds it under its id', () => { + queuedMessages = [card('kept')] + expect(keptRows()).toEqual([]) +}) + +it('draws it as not sent once no card holds it', () => { + expect(keptRows()).toEqual([{ id: KEPT_ITEM.itemId, unsent: true }]) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index cc0d0240b869..bed8337dd529 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -84,7 +84,8 @@ export function projectStructuredAgentSessionMessages( const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] for (const item of items) { - if (inPlace.has(item.itemId)) { + // A command such as `/compact` has its rejection reported as its own reply, so it stays hidden. + if (inPlace.has(item.itemId) && !(item.body.kind === 'message' && item.body.command)) { unsentItems.push(item) } else if (!rejected.has(item.itemId)) { visibleItems.push(item) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 54efaa1326be..f1c8a58cd68e 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -15,8 +15,7 @@ import { structuredAgentSessionPayloadFingerprint } from './structured-agent-ses import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `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 only for the - * journal, whose row then shows it (`structuredAgentSessionEntryRejectedByHost`). */ + * it; only the user's Retry does. One the host recorded leaves once the journal's row shows it. */ export type StructuredAgentSessionOutboxState = | 'queued' | 'dispatching' @@ -174,8 +173,8 @@ export function structuredAgentSessionEntryIdExpired( ) } -/** The host recorded this send and then rejected it: its journal row is the message from here. The - * entry offers no Retry and draws it only until the journal carries it; a remount drops it. */ +/** The host recorded this send and then rejected it: the reconcile drops it once the journal's + * submissions say so, and its row is the message from then on. */ export function structuredAgentSessionEntryRejectedByHost( entry: StructuredAgentSessionOutboxEntry ): boolean { From 2783ccc55ba1ff3c5d9549abe0c1f507aa9c6c51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:56:45 -0700 Subject: [PATCH 09/19] fix(native-chat): draw a queued message where the host rejected it A message accepted to hand over later and rejected before any handover now sits at its rejection, as a handover places one: what the agent did while it waited happened before it, and the newest history page holds it. One handed over, or dispatched as it was recorded, keeps its place. An older host does not move it, so it stays at its submission, still drawn. A failed start now rejects the queued messages and writes its row in ONE journal append, the messages first: no reader ever meets one without the other, and the messages still draw above the row that says why. --- .../journal-dispatch-reducer.ts | 8 +- .../journal-lifecycle-batch-appender.ts | 23 ++++ .../journal-pending-submission-recovery.ts | 21 ++++ .../journal-queued-rejection-batch.test.ts | 111 ++++++++++++++++ .../agent-session-journal/journal-reducer.ts | 6 +- .../journal-rejected-queued-placement.test.ts | 118 ++++++++++++++++++ .../journal-row-writer.ts | 34 +++++ .../journal-store-collaborators.ts | 32 ++--- .../journal-store-contracts.ts | 3 + .../journal-submission-fold.ts | 16 +++ ...d-agent-session-conversation-close.test.ts | 12 +- ...uctured-agent-session-start-failure-row.ts | 13 +- ...ssage-projection.rejected-in-place.test.ts | 19 +++ 13 files changed, 383 insertions(+), 33 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 89722959799d..01a1b686d6a7 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -8,7 +8,11 @@ import { import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalDispatchRowApplies } from './journal-dispatch-settlement' import type { JournalReducerState } from './journal-reducer' -import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold' +import { + notePersonTurnAccepted, + placeHandedOverMessage, + placeRejectedQueuedMessage +} from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' export function applyJournalDispatchRow( @@ -34,6 +38,8 @@ export function applyJournalDispatchRow( if (row.state === 'pending') { submission.handedOverAt = row.ts placeHandedOverMessage(state, submission, row) + } else if (row.state === 'rejected') { + placeRejectedQueuedMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index c05d22a87457..ef4309347ba5 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -3,6 +3,7 @@ import type { JournalReducerState } from './journal-reducer' import { journalLifecycleBatchRowBuilder } from './journal-row-builders' import type { JournalLifecycleBatchInput } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import { journalQueuedRejectionRowBuilders } from './journal-pending-submission-recovery' const SETTLEMENT_ALREADY_APPLIED = new Error('journal_settlement_already_applied') @@ -12,10 +13,32 @@ export class JournalLifecycleBatchAppender { state: () => JournalReducerState cursor: () => AgentJournalCursor enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueueRows: ( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ) => Promise } ) {} append(input: JournalLifecycleBatchInput): Promise { + const { rejectsQueued } = input + if (rejectsQueued) { + // Planned on the lane: the sends queued then, and this batch unless it already landed. + return this.deps + .enqueueRows(() => [ + ...journalQueuedRejectionRowBuilders(this.deps.state, input.fence, rejectsQueued), + ...(this.wasApplied(input.settlementId) + ? [] + : [ + journalLifecycleBatchRowBuilder( + this.deps.state, + input.settlementId, + input.mutations, + input + ) + ]) + ]) + .then(() => this.deps.cursor()) + } if (this.wasApplied(input.settlementId)) { return Promise.resolve(this.deps.cursor()) } diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts index c252a57d8a57..80fdc19d5296 100644 --- a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -2,6 +2,9 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-sessio import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' +import type { JournalReducerState } from './journal-reducer' +import { journalDispatchRowBuilder } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' /** Settles every submission a process fact left unanswerable. Doubt is never @@ -84,3 +87,21 @@ export async function rejectJournalQueuedSubmissions( } return queued.map((entry) => entry.clientMessageId) } + +/** Rows rejecting every submission still queued, read from `state` when called: for an append + * that must carry them with what follows, in one transaction. */ +export function journalQueuedRejectionRowBuilders( + state: () => JournalReducerState, + fence: number, + rejection: AgentJournalDispatchRejection +): ((seq: number, ts: number) => JournalRow)[] { + return [...state().submissions.values()].filter(isQueuedAgentJournalSubmission).map((entry) => + journalDispatchRowBuilder(state, { + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...rejection, + fence, + recovered: true + }) + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts new file mode 100644 index 000000000000..6efc197ae30b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -0,0 +1,111 @@ +// A failed start's row and the queued messages it failed land in ONE append, the messages first: +// no reader meets one without the other, and the messages sit above the row that says why. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } 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 { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-start', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { + surface: 'rejection' +}) +const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) +}) + +afterEach(async () => { + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +async function openWithQueued(...ids: string[]): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => 'epoch-1' + }) + for (const id of ids) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 0, + handoverRecorded: true + }) + } + return journal +} + +function startFailureBatch(mutations = 1) { + return { + settlementId: 'start-failure:gen-1', + fence: 0, + recovered: true as const, + mutations: Array.from({ length: mutations }, () => ({ + kind: 'item' as const, + identity: ERROR_ROW, + body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), + rejectsQueued: START_FAILED + } +} + +it('writes the rejections first and the row after them, and draws the messages above it', async () => { + const journal = await openWithQueued('first', 'second') + const before = journal.cursor().sequence + + await journal.appendLifecycleBatch(startFailureBatch()) + + expect(journal.cursor().sequence).toBe(before + 3) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'rejected', + 'rejected' + ]) + const order = journal.snapshot().items.map((item) => item.itemId) + expect(order).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second'), + 'orca:start-failure%3Agen-1' + ]) +}) + +it('writes neither when the row cannot be written', async () => { + const journal = await openWithQueued('first') + const before = journal.cursor().sequence + + // An empty batch breaks the row's bound, so the transaction rolls back as a whole. + await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( + 'journal_lifecycle_batch_mutation_bound_exceeded' + ) + + expect(journal.cursor().sequence).toBe(before) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..f8e9751ac989 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -6,9 +6,9 @@ // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item // (a later revision updates the body, it does not move the bubble) — except a -// queued message, which sits where its handover put it. Producer linkage is -// likewise the creating write's: a revision naming no producer keeps it, one -// naming any replaces it. +// queued message, which sits where its handover, or a rejection before any, put +// it. Producer linkage is likewise the creating write's: a revision naming no +// producer keeps it, one naming any replaces it. import type { AgentJournalAcceptanceReceipt, diff --git a/src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts b/src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts new file mode 100644 index 000000000000..97fa1cad3736 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts @@ -0,0 +1,118 @@ +// A message the host accepted to hand over later and then rejected before any handover sits where +// it was rejected: what the agent did while it waited happened before it. + +import { describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' +import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders' +import type { JournalRow } from './journal-row-schema' + +function journal() { + const state = createJournalReducerState('session-1', 'epoch-1') + let seq = 0 + const push = (next: JournalRow): JournalRow => { + applyJournalRow(state, next) + return next + } + return { + state, + submission(clientMessageId: string, handoverRecorded = true) { + seq += 1 + return push( + buildJournalSubmissionRow({ + state, + clientMessageId, + payloadFingerprint: `fp-${clientMessageId}`, + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: clientMessageId }] + }, + seq, + fence: 1, + ts: 1_000 + seq, + ...(handoverRecorded ? { handoverRecorded: true } : {}) + }) + ) + }, + dispatch(clientMessageId: string, state_: 'pending' | 'rejected') { + seq += 1 + return push({ + kind: 'dispatch', + clientMessageId, + state: state_, + providerItemId: null, + reason: state_ === 'rejected' ? DISPATCH_REJECTED_HOST_RESTARTED : null, + ...(state_ === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}), + ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + }, + /** Other work between the send and its settlement, as a turn running ahead of it would write. */ + advance(rows: number) { + seq += rows + state.lastSequence = seq + } + } +} + +function placed(state: ReturnType['state'], clientMessageId: string) { + const item = state.items.get(agentJournalSubmissionKey(clientMessageId)) + return item && { sequence: item.sequence, observedAt: item.observedAt, scope: item.turnScope } +} + +describe('a queued message rejected before its handover', () => { + it('sits at the rejection, in no turn', () => { + const { state, submission, dispatch, advance } = journal() + submission('waiting') + advance(300) + dispatch('waiting', 'rejected') + + expect(placed(state, 'waiting')).toEqual({ + sequence: 302, + observedAt: 1_302, + scope: AGENT_JOURNAL_THREAD_SCOPE + }) + }) + + it('reaches a subscriber at the tail, so the newest page holds it', () => { + const { state, submission, dispatch, advance } = journal() + submission('waiting') + advance(300) + const rejection = dispatch('waiting', 'rejected') + + const projected = projectJournalBatch({ + rows: [rejection], + snapshot: renderJournalState(state), + afterSequence: 301 + }) + expect(projected.ok && projected.batch.items.map((item) => item.sequence)).toEqual([302]) + expect(renderJournalState(state).items.at(-1)?.itemId).toBe( + agentJournalSubmissionKey('waiting') + ) + }) + + it('keeps one handed over before its rejection where the handover put it', () => { + const { state, submission, dispatch, advance } = journal() + submission('steer') + advance(10) + dispatch('steer', 'pending') + advance(10) + dispatch('steer', 'rejected') + + expect(placed(state, 'steer')?.sequence).toBe(12) + }) + + it('keeps a send dispatched as it was recorded where it was sent', () => { + const { state, submission, dispatch, advance } = journal() + submission('direct', false) + advance(10) + dispatch('direct', 'rejected') + + expect(placed(state, 'direct')?.sequence).toBe(1) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index a29585580ebe..6a4f322bdd5f 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -59,6 +59,40 @@ export class JournalRowWriter { }) } + /** Several rows in ONE transaction, in order, planned once the lane is this append's: none is + * durable unless all are, so no reader ever meets some without the rest. */ + enqueueRows( + plan: () => readonly ((seq: number, ts: number) => JournalRow)[] + ): Promise { + return this.deps.serialize(async () => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + const first = this.deps.nextSequence() + const ts = this.deps.now() + const rows = plan().map((build, index) => build(first + index, ts)) + if (rows.length === 0) { + return rows + } + for (const row of rows) { + assertJournalFence(row.fence, this.deps.highestFence()) + } + try { + this.deps.database().transaction((db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row) + this.runBookkeeping(db, row) + } + }) + } catch (error) { + this.deps.rolledBack?.() + throw error + } + for (const row of rows) { + this.deps.commit(row) + } + return rows + }) + } + /** Assign the next sequence, make the row durable, and fold it through the SAME reducer * replay uses — all inside one serialized step — answering where the row landed. */ append( diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index b938efa3c5c9..eae948888eb8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -91,6 +91,20 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), committed: host.notifyCommitted }) + const rowWriter = new JournalRowWriter({ + sessionId: host.identity.sessionId, + now: host.now, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + highestFence: () => host.state().highestFence, + nextSequence: () => host.state().lastSequence + 1, + commit: host.commit, + // Every rejection is a dispatch row through this one writer; the draft + // returned-transition rides it so no path can bypass the hook. + inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), + rolledBack: () => queuedMessages.invalidate() + }) return { epochController, queuedMessages, @@ -101,20 +115,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal restoreJournalStore(host, { epochController }).then(() => queuedMessages.repairAndPruneAtOpen() ), - rowWriter: new JournalRowWriter({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - highestFence: () => host.state().highestFence, - nextSequence: () => host.state().lastSequence + 1, - commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate() - }), + rowWriter, itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue @@ -122,7 +123,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal lifecycleBatchAppender: new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, - enqueue: host.enqueue + enqueue: host.enqueue, + enqueueRows: (plan) => rowWriter.enqueueRows(plan) }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 85e6b99f9029..cc18885b7062 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -70,6 +70,9 @@ export type JournalLifecycleBatchInput = { mutations: readonly JournalLifecycleMutationInput[] fence: number recovered?: true + /** Rejects the sends still queued with this first, in the same append: a failed start's row + * follows the messages it failed, and no reader meets one without the other. */ + rejectsQueued?: AgentJournalDispatchRejection } export type JournalSubmissionInput = { diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 2c9c6cfcd040..def4858e14cb 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -69,6 +69,22 @@ export function placeHandedOverMessage( }) } +/** A queued message rejected before any handover joins the conversation where it was rejected, as + * a handover places one: what the agent did while it waited happened before it. In no turn. */ +export function placeRejectedQueuedMessage( + state: JournalReducerState, + submission: AgentJournalSubmission, + row: Extract +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + if (!submission.handoverRecorded || submission.handedOverAt !== undefined || !item) { + return + } + const { sequenceIndex: _acceptedAt, ...accepted } = item + state.items.set(itemId, { ...accepted, sequence: row.seq, observedAt: row.ts }) +} + export function acceptSubmissionFromProviderItem( state: JournalReducerState, providerItemId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index c084d2b2e07c..2dac4cacb7df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -241,16 +241,16 @@ describe('a start that never finishes (P2-15)', () => { providerChildPhase: 'starting' as const })) Object.assign(rig.host.deps.adapter, { awaitStarted: () => started.promise }) - const reject = AgentSessionJournal.prototype.rejectQueuedSubmissions - vi.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions').mockImplementation(function ( + // The loop rejects the queued messages in the same append as its row. + const append = AgentSessionJournal.prototype.appendLifecycleBatch + vi.spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch').mockImplementation(function ( this: AgentSessionJournal, ...args ) { - // Not the open's sweep of an earlier process's leftovers. - if (args[1].rejection.kind !== 'hostRestarted') { - order.push(`rejected: ${args[1].reason}`) + if (args[0].rejectsQueued) { + order.push(`rejected: ${args[0].rejectsQueued.reason}`) } - return reject.apply(this, args) + return append.apply(this, args) }) const reader = collectSubscriber() const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts index 12fa05710f00..9206f95fde0b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-row.ts @@ -42,9 +42,9 @@ export function hasStructuredAgentSessionStartFailureRow( } /** - * A start the delivery loop needed and did not get: the start's row, and every queued message - * rejected with the same words. Writes nothing when nothing is still queued: a start whose - * messages Stop withdrew did not fail anyone. + * A start the delivery loop needed and did not get: every queued message rejected with the same + * words, then the start's row. Writes nothing when nothing is still queued: a start whose messages + * Stop withdrew did not fail anyone. */ export async function recordStructuredAgentSessionStartFailure( session: Pick & { fence: number }, @@ -59,11 +59,8 @@ export async function recordStructuredAgentSessionStartFailure( settlementId: `start-failure:${startKey}`, fence: session.fence, recovered: true, - mutations: [structuredAgentSessionStartFailureRow(startKey, failure)] - }) - await session.journal.rejectQueuedSubmissions(session.fence, { - reason: failure.reason, - rejection: failure.rejection + mutations: [structuredAgentSessionStartFailureRow(startKey, failure)], + rejectsQueued: { reason: failure.reason, rejection: failure.rejection } }) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index f91814597dfd..20da7140f22c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -144,6 +144,25 @@ describe('a message the host accepted and then rejected, on the desktop', () => ).toMatchObject({ journalPosition: { sequence: 3, index: 0 }, source: 'transcript' }) }) + // An older host never moves it to its rejection: it stays at its submission, still drawn. + it('keeps the position an older host gave it, however far back', () => { + const later = Array.from({ length: 300 }, (_, index) => answer(4 + index)) + const items = [...SEED_ROWS, userItem('waited', 3, 'fix the parser'), ...later] + const messages = projectStructuredAgentSessionMessages( + items, + [], + [SEED, restartRejected('waited', 'fix the parser', 3)], + NO_CARDS + ) + + expect( + messages.find((message) => message.id === agentJournalSubmissionKey('waited')) + ).toMatchObject({ + unsent: true, + journalPosition: { sequence: 3, index: 0 } + }) + }) + it('says why from the host fact, with no Retry', () => { const notices = structuredAgentSessionDeliveryNotices( [], From 871160770d41d1c6aa1c751c5e566765028aae97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:57:09 -0700 Subject: [PATCH 10/19] test(native-chat): pin that rows written together roll back together --- .../journal-row-writer.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index aa0078b88a59..23c73f749dae 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -91,4 +91,17 @@ describe('journal row writer', () => { kind: 'item' }) }) + + it('writes several rows as one: a failure on the last leaves none', async () => { + const { writer, committedRows } = writerHarness() + // Sequence 2 is taken, so the second of the two rows violates the primary key. + insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) + + await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() + + expect(committedRows).toHaveLength(0) + expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual( + [2] + ) + }) }) From 31ae1c1f57823633aa38ce9a1d008c95e185e55a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:14:22 -0700 Subject: [PATCH 11/19] fix(native-chat): draw every rejected message where it was rejected Not only a queued message: one handed over into a turn and then rejected, or sent directly and rejected, also sits at its rejection, in no turn. A message in doubt stays where it was, a plain bubble: it may have reached the agent. --- .../journal-dispatch-reducer.ts | 4 +- .../journal-reducer.test.ts | 5 +- .../agent-session-journal/journal-reducer.ts | 6 +- ...ournal-rejected-message-placement.test.ts} | 66 +++++++++++++++---- .../journal-submission-fold.ts | 18 +++-- 5 files changed, 73 insertions(+), 26 deletions(-) rename src/main/native-chat/agent-session-journal/{journal-rejected-queued-placement.test.ts => journal-rejected-message-placement.test.ts} (59%) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 01a1b686d6a7..c7c2a6716546 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -11,7 +11,7 @@ import type { JournalReducerState } from './journal-reducer' import { notePersonTurnAccepted, placeHandedOverMessage, - placeRejectedQueuedMessage + placeRejectedMessage } from './journal-submission-fold' import type { JournalRow } from './journal-row-schema' @@ -39,7 +39,7 @@ export function applyJournalDispatchRow( submission.handedOverAt = row.ts placeHandedOverMessage(state, submission, row) } else if (row.state === 'rejected') { - placeRejectedQueuedMessage(state, submission, row) + placeRejectedMessage(state, submission, row) } if (row.recovered) { submission.recovered = row.recovered diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 45681f328710..60d068ebfd8c 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -551,7 +551,7 @@ describe('submission and dispatch state machine', () => { expect(state.receipts.get('cm_1')).toBeTruthy() }) - it('keeps a refused write rejected and leaves its bubble where it was', () => { + it('keeps a refused write rejected, at its rejection, whatever comes after', () => { const state = fold([ submission, { @@ -579,7 +579,8 @@ describe('submission and dispatch state machine', () => { submittedAt: submission.ts, reason: 'provider_write_failed: closed before enqueue' }) - expect(renderJournalState(state).items[0]?.sequence).toBe(submission.seq) + // It sits where it was rejected; the late `pending` moves nothing. + expect(renderJournalState(state).items[0]?.sequence).toBe(2) }) it('ignores a dispatch for a submission this epoch never saw', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index f8e9751ac989..e2d1b1ef3ca3 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -6,9 +6,9 @@ // dropped rather than resurrecting stale content, and ordering is by the // position (sequence, then place in the row) of the write that CREATED an item // (a later revision updates the body, it does not move the bubble) — except a -// queued message, which sits where its handover, or a rejection before any, put -// it. Producer linkage is likewise the creating write's: a revision naming no -// producer keeps it, one naming any replaces it. +// queued message, which sits where its handover put it, and a rejected one, which +// sits where it was rejected. Producer linkage is likewise the creating write's: a +// revision naming no producer keeps it, one naming any replaces it. import type { AgentJournalAcceptanceReceipt, diff --git a/src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts similarity index 59% rename from src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts rename to src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts index 97fa1cad3736..ce5b407d3e84 100644 --- a/src/main/native-chat/agent-session-journal/journal-rejected-queued-placement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-rejected-message-placement.test.ts @@ -1,11 +1,16 @@ -// A message the host accepted to hand over later and then rejected before any handover sits where -// it was rejected: what the agent did while it waited happened before it. +// A rejected message sits where it was rejected, in no turn: queued, handed over or sent directly. +// One in doubt stays where it was: it may have reached the agent. import { describe, expect, it } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalTurnScope +} from '../../../shared/agent-session-journal-types' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { DISPATCH_DOUBT_HOST_RESTARTED } from './journal-dispatch-doubt-reasons' import { applyJournalRow, createJournalReducerState, renderJournalState } from './journal-reducer' import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' @@ -39,17 +44,26 @@ function journal() { }) ) }, - dispatch(clientMessageId: string, state_: 'pending' | 'rejected') { + dispatch( + clientMessageId: string, + state_: 'pending' | 'rejected' | 'unknown', + turnScope: AgentJournalTurnScope = AGENT_JOURNAL_THREAD_SCOPE + ) { seq += 1 return push({ kind: 'dispatch', clientMessageId, state: state_, providerItemId: null, - reason: state_ === 'rejected' ? DISPATCH_REJECTED_HOST_RESTARTED : null, + reason: + state_ === 'rejected' + ? DISPATCH_REJECTED_HOST_RESTARTED + : state_ === 'unknown' + ? DISPATCH_DOUBT_HOST_RESTARTED + : null, ...(state_ === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}), ...journalRowBase(state.epoch, seq, 1, 1_000 + seq), - turnScope: AGENT_JOURNAL_THREAD_SCOPE + turnScope }) }, /** Other work between the send and its settlement, as a turn running ahead of it would write. */ @@ -65,8 +79,10 @@ function placed(state: ReturnType['state'], clientMessageId: str return item && { sequence: item.sequence, observedAt: item.observedAt, scope: item.turnScope } } -describe('a queued message rejected before its handover', () => { - it('sits at the rejection, in no turn', () => { +const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: 'orca:turn-1' } + +describe('a rejected message', () => { + it('sits at the rejection, in no turn, when it was queued', () => { const { state, submission, dispatch, advance } = journal() submission('waiting') advance(300) @@ -96,23 +112,47 @@ describe('a queued message rejected before its handover', () => { ) }) - it('keeps one handed over before its rejection where the handover put it', () => { + it('sits at the rejection, out of the turn it was handed into, when it was a steer', () => { const { state, submission, dispatch, advance } = journal() submission('steer') advance(10) - dispatch('steer', 'pending') + dispatch('steer', 'pending', IN_TURN) + expect(placed(state, 'steer')?.scope).toEqual(IN_TURN) advance(10) dispatch('steer', 'rejected') - expect(placed(state, 'steer')?.sequence).toBe(12) + expect(placed(state, 'steer')).toEqual({ + sequence: 23, + observedAt: 1_023, + scope: AGENT_JOURNAL_THREAD_SCOPE + }) }) - it('keeps a send dispatched as it was recorded where it was sent', () => { + it('sits at the rejection when it was sent directly', () => { const { state, submission, dispatch, advance } = journal() submission('direct', false) advance(10) dispatch('direct', 'rejected') - expect(placed(state, 'direct')?.sequence).toBe(1) + expect(placed(state, 'direct')?.sequence).toBe(12) + }) +}) + +describe('a message in doubt', () => { + it('stays where it was handed over, a plain bubble in its turn: it may have reached the agent', () => { + const { state, submission, dispatch, advance } = journal() + submission('steer') + advance(10) + dispatch('steer', 'pending', IN_TURN) + advance(10) + dispatch('steer', 'unknown') + + expect(placed(state, 'steer')).toEqual({ sequence: 12, observedAt: 1_012, scope: IN_TURN }) + const { items, submissions } = renderJournalState(state) + const drawn = projectStructuredAgentSessionMessages(items, [], submissions, { + rejectedInPlace: true + }).find((message) => message.id === agentJournalSubmissionKey('steer')) + expect(drawn).toBeDefined() + expect(drawn?.unsent).toBeUndefined() }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index def4858e14cb..80e4e2c93e95 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -69,20 +69,26 @@ export function placeHandedOverMessage( }) } -/** A queued message rejected before any handover joins the conversation where it was rejected, as - * a handover places one: what the agent did while it waited happened before it. In no turn. */ -export function placeRejectedQueuedMessage( +/** A rejected message — queued, handed over, or sent directly — joins the conversation where it was + * rejected, in no turn: what happened before the rejection happened before it, and the newest page + * holds a recent one. Only a rejection: one in doubt may have reached the agent, so it stays. */ +export function placeRejectedMessage( state: JournalReducerState, submission: AgentJournalSubmission, row: Extract ): void { const itemId = agentJournalSubmissionKey(submission.clientMessageId) const item = state.items.get(itemId) - if (!submission.handoverRecorded || submission.handedOverAt !== undefined || !item) { + if (row.state !== 'rejected' || !item) { return } - const { sequenceIndex: _acceptedAt, ...accepted } = item - state.items.set(itemId, { ...accepted, sequence: row.seq, observedAt: row.ts }) + const { sequenceIndex: _placed, ...rest } = item + state.items.set(itemId, { + ...rest, + sequence: row.seq, + observedAt: row.ts, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) } export function acceptSubmissionFromProviderItem( From a664eb26b9de3f024c44d8c191c6b1ec9f5034a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:19:02 -0700 Subject: [PATCH 12/19] fix(native-chat): decide a rejected message's Retry from the host's stored fact - a message the host recorded and then rejected has no Retry on any mount, however that mount learned of it, and a Dismiss that clears it from storage; a send refused before the host recorded it keeps its Retry - the rule that keeps a rejected command such as /compact out of the transcript moves into the one visibility function rows and notices share - the outbox state docs say what lets a recorded message go: the client holding its rejected submission, whose row the host places at the rejection --- ...iveChatMessageList.unsent-message.test.tsx | 1 + ...tiveChatStructuredSession.test-harness.tsx | 1 + .../NativeChatStructuredSession.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 1 + ...red-agent-session-delivery-notices.test.ts | 51 +++++++++++-- ...ructured-agent-session-delivery-notices.ts | 33 +++++--- ...ssage-projection.rejected-in-place.test.ts | 56 ++++++++++++-- .../structured-agent-session-outbox-retry.ts | 3 +- ...ructured-agent-session-delivery-notices.ts | 52 +++++++++++-- ...ed-agent-session-outbox-admission.test.tsx | 1 + ...nt-session-outbox-rejection-cause.test.tsx | 76 ++++++++++++++----- ...gent-session-outbox-relaunch-hold.test.tsx | 1 + .../use-structured-agent-session-outbox.ts | 11 +++ .../use-structured-agent-session.ts | 1 + ...ctured-agent-session-message-projection.ts | 29 +++++-- src/shared/structured-agent-session-outbox.ts | 8 +- 16 files changed, 266 insertions(+), 60 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx index ac6ab467ace2..2429554c7989 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx @@ -267,6 +267,7 @@ describe('a message the host rejected after a crash, with no outbox entry left', [], 'Claude', vi.fn(), + () => {}, submissions, [], new Set() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 4d81b390983b..bec1ffc98aab 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -180,6 +180,7 @@ export function createStructuredSessionMocks() { submissions: mocks.submissions, send: outbox.send, retry: outbox.retry, + dismiss: outbox.dismiss, isWorking: mocks.isWorking, backgroundTasks: { show: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 96cc16b15ba3..92582ef3b2f5 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -117,6 +117,7 @@ export function NativeChatStructuredSession( failedHere: controller.failedHere, queuedMessageIds: controller.queuedMessageIds, retry: controller.retry, + dismiss: controller.dismiss, agentName: agentLabel }) const viewState = selectNativeChatViewState(session, { readRetries: true }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 68012334116b..986c3f51cfb9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -85,6 +85,7 @@ vi.mock('./use-structured-agent-session', async () => { submissions: mocks.submissions, send: outbox.send, retry: outbox.retry, + dismiss: outbox.dismiss, isWorking: false, isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks, supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop, 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 19855303d602..0012be3a1aca 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 @@ -48,6 +48,7 @@ function texts( outbox, 'Claude', () => {}, + () => {}, submissions, startFailures, new Set(outbox.map((candidate) => candidate.clientMessageId)) @@ -56,7 +57,7 @@ function texts( } describe('the notice on each message that did not go through', () => { - // Rejected while the chat watched, so the host has them: no Retry, the journal row takes over. + // Recorded by the host, so sending one again is a new message: no Retry. it('gives two messages the host rejected each their own reason and no Retry', () => { const retry = vi.fn() const notices = structuredAgentSessionDeliveryNotices( @@ -76,6 +77,7 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', retry, + () => {}, [], [], new Set(['first', 'second']) @@ -95,9 +97,10 @@ describe('the notice on each message that did not go through', () => { expect(notices.get(agentJournalSubmissionKey('second'))?.onRetry).toBeUndefined() }) - // Read back from storage with no row loaded: the host may have lost it, so it keeps its Retry. - it('keeps the Retry on a message the host rejected before this chat opened', () => { + // However this chat learned of it: the host recorded it, so it has a Dismiss and never a Retry. + it('gives a message the host rejected before this chat opened a Dismiss, not a Retry', () => { const retry = vi.fn() + const dismiss = vi.fn() const notice = structuredAgentSessionDeliveryNotices( [ entry('earlier', { @@ -107,12 +110,35 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', retry, + dismiss, [], [], NOT_FAILED_HERE ).get(agentJournalSubmissionKey('earlier')) - notice?.onRetry?.() - expect(retry).toHaveBeenCalledExactlyOnceWith('earlier') + expect(notice?.onRetry).toBeUndefined() + notice?.onDismiss?.() + expect(dismiss).toHaveBeenCalledExactlyOnceWith('earlier') + expect(retry).not.toHaveBeenCalled() + }) + + // Refused before the host recorded it: only its Retry sends it, so it keeps one. + it('keeps the Retry on a message refused before the host recorded it', () => { + const notice = structuredAgentSessionDeliveryNotices( + [ + entry('refused', { + state: 'rejected', + lastFailure: { kind: 'refused', code: 'agent_session_owner_restart_failed' } + }) + ], + 'Claude', + () => {}, + () => {}, + [], + [], + NOT_FAILED_HERE + ).get(agentJournalSubmissionKey('refused')) + expect(notice?.onRetry).toBeDefined() + expect(notice?.onDismiss).toBeUndefined() }) it('chooses the words from the saved refusal on a refused message', () => { @@ -203,6 +229,7 @@ describe('the notice on each message that did not go through', () => { outbox, 'Claude', retry, + () => {}, [], [], NOT_FAILED_HERE @@ -234,6 +261,7 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', retry, + () => {}, [], [], NOT_FAILED_HERE @@ -421,6 +449,7 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', vi.fn(), + () => {}, [], [], NOT_FAILED_HERE @@ -444,9 +473,15 @@ describe('the notice on each message that did not go through', () => { } }) const words = (failedHere: ReadonlySet) => - structuredAgentSessionDeliveryNotices([held], 'Claude', vi.fn(), [], [], failedHere).get( - agentJournalSubmissionKey('held') - ) + structuredAgentSessionDeliveryNotices( + [held], + 'Claude', + vi.fn(), + () => {}, + [], + [], + failedHere + ).get(agentJournalSubmissionKey('held')) expect(words(NOT_FAILED_HERE)).toMatchObject({ text: 'Your message was not sent.' }) expect(words(NOT_FAILED_HERE)?.onRetry).toBeDefined() expect(words(new Set(['held']))?.text).toBe( 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 9ddb23707a7d..e15010ab87d7 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 @@ -10,7 +10,8 @@ // A message the host recorded and then rejected is drawn from the host's history, worded from the // journal's own fact, with no Retry: sending it again is a new message. A rejection that is a // failed start's, the fact its loaded row states, says only that it was not sent: the row already -// says why. Until the journal's submissions say so, the outbox draws it from its smaller copy. +// says why. Until the journal's submissions say so, the outbox draws it from its smaller copy, with +// a Dismiss in case they never do. import { readAgentSessionFailureFact, @@ -43,6 +44,8 @@ import { translate } from '@/i18n/i18n' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' +const NO_COMMANDS: ReadonlySet = new Set() + /** The facts the chat's loaded start-failure rows state. */ export function structuredAgentSessionStartFailureFacts( items: readonly AgentJournalRenderItem[] @@ -146,6 +149,8 @@ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, retry: (clientMessageId: string) => void, + /** Clears a message the host recorded and rejected from the outbox, which draws it until then. */ + dismiss: (clientMessageId: string) => void, /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ @@ -153,7 +158,9 @@ export function structuredAgentSessionDeliveryNotices( /** Ids whose send failed or was refused while this chat was open: only they word their cause. */ failedHere: ReadonlySet, /** The queue's live cards, which the transcript leaves a rejected message to. */ - queuedMessageIds: readonly string[] = [] + queuedMessageIds: readonly string[] = [], + /** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */ + commandItemIds: ReadonlySet = NO_COMMANDS ): ReadonlyMap { const admission = admitStructuredAgentSessionOutboxEntry(outbox) const held = admission.state === 'blocked' ? admission.entry.clientMessageId : null @@ -170,21 +177,27 @@ export function structuredAgentSessionDeliveryNotices( structuredAgentSessionEntryHeldForRetry(entry) || entry.clientMessageId === held ) { - // Its own Retry is the step, so the words leave out sending again. One the host rejected - // while this chat watched waits for its journal row instead: the host has it. Read back from - // storage with no row loaded, the host may have lost it, so it keeps its Retry. - const retryControl = - (stalledFrom === -1 || index <= stalledFrom) && - !(structuredAgentSessionEntryRejectedByHost(entry) && failedHere.has(entry.clientMessageId)) + // Its own Retry is the step, so the words leave out sending again. One the host recorded is + // the host's: sending it again is a new message, so it has no Retry, only a Dismiss. + const recorded = structuredAgentSessionEntryRejectedByHost(entry) + const retryControl = (stalledFrom === -1 || index <= stalledFrom) && !recorded const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere) notices.set( agentJournalSubmissionKey(entry.clientMessageId), - retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } + recorded + ? { text, onDismiss: () => dismiss(entry.clientMessageId) } + : retryControl + ? { text, onRetry: () => retry(entry.clientMessageId) } + : { text } ) } } // After the outbox's: the host's row is the message, whatever the outbox still holds under its id. - const shown = structuredAgentSessionRejectedShownInPlace(submissions, queuedMessageIds) + const shown = structuredAgentSessionRejectedShownInPlace( + submissions, + queuedMessageIds, + commandItemIds + ) for (const submission of rejected.values()) { const id = agentJournalSubmissionKey(submission.clientMessageId) if (shown.has(id)) { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 20da7140f22c..1853f521fe71 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -11,7 +11,10 @@ import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' -import { projectStructuredAgentSessionMessages as projectShared } from '../../../../shared/structured-agent-session-message-projection' +import { + projectStructuredAgentSessionMessages as projectShared, + structuredAgentSessionCommandItemIds +} from '../../../../shared/structured-agent-session-message-projection' import { createStructuredAgentSessionOutboxEntry, type StructuredAgentSessionOutboxEntry @@ -168,6 +171,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => [], 'Claude', () => {}, + () => {}, [SEED, restartRejected('lost', 'fix the parser', 3)], [], new Set() @@ -190,6 +194,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => [], 'Claude', () => {}, + () => {}, [failedStart], [{ kind: 'notSignedIn' }], new Set() @@ -281,6 +286,20 @@ describe('a message the host accepted and then rejected, on the desktop', () => projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS) ) ).toEqual([{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }]) + // One rule decides for the rows and the notices. + expect( + structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + () => {}, + submissions, + [], + new Set(), + NO_CARDS, + structuredAgentSessionCommandItemIds([...SEED_ROWS, compact]) + ).size + ).toBe(0) }) it('keeps a message a Stop withdrew hidden: it went back to its sender', () => { @@ -291,7 +310,15 @@ describe('a message the host accepted and then rejected, on the desktop', () => { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } ]) expect( - structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size + structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + () => {}, + submissions, + [], + new Set() + ).size ).toBe(0) }) }) @@ -319,6 +346,7 @@ describe("one row per rejected message, the host's once it records the rejection [held], 'Claude', () => {}, + () => {}, [SEED], [], new Set(['held']) @@ -346,6 +374,7 @@ describe("one row per rejected message, the host's once it records the rejection [entry], 'Claude', () => {}, + () => {}, [SEED, rejected], [], new Set() @@ -404,7 +433,15 @@ describe('a rejected message the queue holds', () => { seedRow ]) expect( - structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size + structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + () => {}, + submissions, + [], + new Set() + ).size ).toBe(0) }) @@ -417,9 +454,16 @@ describe('a rejected message the queue holds', () => { seedRow ]) expect( - structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [ - 'kept' - ]).size + structuredAgentSessionDeliveryNotices( + [], + 'Claude', + () => {}, + () => {}, + submissions, + [], + new Set(), + ['kept'] + ).size ).toBe(0) expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([ seedRow, 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 8f93a3ed5160..af2c1bedc68b 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 @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { structuredAgentSessionEntryIdExpired, + structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { @@ -28,7 +29,7 @@ export function retryStructuredAgentSessionOutboxEntry(args: { // settled the message already rotated it. An expired id is refused for good; its row told the // user to check the chat first. const recordedRejection = - current?.state === 'rejected' && current.lastFailure?.kind === 'rejected' + current !== undefined && structuredAgentSessionEntryRejectedByHost(current) if ( current && (recordedRejection || 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 ddc76998ec03..9bb89e80bd27 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 @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { structuredAgentSessionCommandItemIds } from '../../../../shared/structured-agent-session-message-projection' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { useStructuredAgentSessionStartFailureFacts } from './use-structured-agent-session-start-failure-facts' @@ -19,16 +20,20 @@ export function useStructuredAgentSessionDeliveryNotices(args: { failedHere: ReadonlySet queuedMessageIds: readonly string[] retry: (clientMessageId: string) => void + dismiss: (clientMessageId: string) => void agentName: string }): ReadonlyMap { const { agentName, failedHere, outbox, queuedMessageIds, submissions } = args - // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. - const retryRef = useRef(args.retry) + // Read at click time, so the notices stay put while the outbox's actions are rebuilt each render. + const actionsRef = useRef({ retry: args.retry, dismiss: args.dismiss }) useEffect(() => { - retryRef.current = args.retry + actionsRef.current = { retry: args.retry, dismiss: args.dismiss } }) const retry = useCallback((clientMessageId: string) => { - retryRef.current(clientMessageId) + actionsRef.current.retry(clientMessageId) + }, []) + const dismiss = useCallback((clientMessageId: string) => { + actionsRef.current.dismiss(clientMessageId) }, []) // Only a message shown as not sent reads the journal's rows (a withdrawn one draws nothing), so // in a chat without one a new batch of them re-renders no row. @@ -39,18 +44,31 @@ export function useStructuredAgentSessionDeliveryNotices(args: { ) const rejectionRows = hasRejected ? submissions : NO_SUBMISSIONS const startFailures = useStructuredAgentSessionStartFailureFacts(args.journalItems, hasRejected) + const commandItemIds = useCommandItemIds(args.journalItems, hasRejected) const notices = useMemo( () => structuredAgentSessionDeliveryNotices( outbox, agentName, retry, + dismiss, rejectionRows, startFailures, failedHere, - queuedMessageIds + queuedMessageIds, + commandItemIds ), - [outbox, agentName, retry, rejectionRows, startFailures, failedHere, queuedMessageIds] + [ + outbox, + agentName, + retry, + dismiss, + rejectionRows, + startFailures, + failedHere, + queuedMessageIds, + commandItemIds + ] ) // A submission batch rebuilds the map; one that says the same keeps the old, so no row re-renders. const previousRef = useRef(notices) @@ -61,6 +79,28 @@ export function useStructuredAgentSessionDeliveryNotices(args: { return stable } +const NO_COMMANDS: ReadonlySet = new Set() + +/** The loaded commands, read only while `enabled`, and held while unchanged so a streaming turn + * rebuilds no notice. */ +function useCommandItemIds( + items: readonly AgentJournalRenderItem[], + enabled: boolean +): ReadonlySet { + const ids = useMemo( + () => (enabled ? structuredAgentSessionCommandItemIds(items) : NO_COMMANDS), + [enabled, items] + ) + const previousRef = useRef(ids) + const previous = previousRef.current + const stable = + previous.size === ids.size && [...ids].every((id) => previous.has(id)) ? previous : ids + useEffect(() => { + previousRef.current = stable + }, [stable]) + return stable +} + /** `next`, reusing each notice `previous` words the same way, and `previous` itself when all are. */ function sameNoticesKept( previous: ReadonlyMap, 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 8a215319ec68..bffb7ed01cdd 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 @@ -282,6 +282,7 @@ describe('structured agent session outbox admission', () => { result.current.outbox, 'Claude', () => {}, + () => {}, [], [], result.current.failedHere diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 987bdcc3d7aa..01af0ddcfbcd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -16,6 +16,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' import { @@ -270,29 +272,63 @@ describe('a send the host rejected because the agent never started', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) - // After a restart nothing in memory remembers the rejection, and its journal row may be older - // than the loaded page, or lost to a repair: the message keeps its Retry, which needs a new id. - it('retries a message rejected before a restart under a new id while its row is not loaded', async () => { - writeOutbox('session-1', [rejectedBeforeRestart()]) + // One stored host fact, one rendering: a message the host recorded and rejected has no Retry on + // any mount, however this mount learned of it, and a Dismiss that clears it from storage. + it('gives a message the host rejected no Retry but a Dismiss, on every mount', async () => { + writeOutbox('session-1', [ + { + ...rejectedBeforeRestart(), + state: 'dispatching', + lastFailure: undefined, + lastAttemptAt: 5 + } + ]) + // The host replays its rejection when the reopened chat asks about the send it left in doubt. mocks.call.mockImplementation(async (_target, _method, params) => - acceptedResultFor(String(params.envelope.clientOperationId)) + rejectedResultFor(String(params.envelope.clientOperationId)) ) - const { result } = renderHook(() => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: { kind: 'local' }, - fence: 1, - submissions: [] - }) - ) - expect(result.current.outbox[0]?.state).toBe('rejected') + const notice = ( + outbox: readonly StructuredAgentSessionOutboxEntry[], + failedHere: ReadonlySet + ) => + structuredAgentSessionDeliveryNotices( + outbox, + 'Claude', + () => {}, + () => {}, + NO_SUBMISSIONS, + [], + failedHere + ).get(agentJournalSubmissionKey('rejected-before-restart')) + const mount = () => + renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: NO_SUBMISSIONS + }) + ) - act(() => result.current.retry('rejected-before-restart')) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const sentId: unknown = mocks.call.mock.calls[0]![2].envelope.clientOperationId - expect(sentId).not.toBe('rejected-before-restart') - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - }) + const first = mount() + await waitFor(() => expect(first.result.current.outbox[0]?.state).toBe('rejected'), { + timeout: 4000 + }) + const onFirst = notice(first.result.current.outbox, first.result.current.failedHere) + first.unmount() + const second = mount() + const onSecond = notice(second.result.current.outbox, second.result.current.failedHere) + + for (const shown of [onFirst, onSecond]) { + expect(shown?.text).toBe(REASON) + expect(shown?.onRetry).toBeUndefined() + expect(shown?.onDismiss).toBeDefined() + } + act(() => second.result.current.dismiss('rejected-before-restart')) + expect(second.result.current.outbox).toEqual([]) + expect(readOutbox('session-1')).toEqual([]) + expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false) + }, 10000) // The host's own record is what lets it go, and storage forgets it too: nothing is owed. it('drops a message rejected before a restart once the journal says so, from storage too', async () => { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index 1caea870a60b..75dac8b22904 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -112,6 +112,7 @@ function notices(outbox: Outbox) { outbox.outbox, 'Claude', outbox.retry, + () => {}, [], [], outbox.failedHere 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..89693455126d 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 @@ -295,12 +295,23 @@ export function useStructuredAgentSessionOutbox(args: { createOperationId: structuredSessionOperationId }) } + // A message the host recorded and rejected, cleared by the user from what the outbox draws. + const dismiss = (clientMessageId: string): void => { + forget(clientMessageId) + commitStructuredAgentSessionOutbox( + sessionId, + getStructuredAgentSessionOutbox(sessionId).filter( + (entry) => entry.clientMessageId !== clientMessageId + ) + ) + } return { outbox, error, failedHere, send, retry, + dismiss, withdrawUnsent } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 7a212f366dfa..562eea0aa62b 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 @@ -225,6 +225,7 @@ export function useStructuredAgentSession(args: { (!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) && outboxController.send(...input), retry: outboxController.retry, + dismiss: outboxController.dismiss, isWorking: transportState.isWorking, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index bed8337dd529..c0af9ac8cb3a 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -17,14 +17,27 @@ export type StructuredAgentSessionMessageProjectionOptions = { queuedMessageIds?: readonly string[] } +/** The loaded items that are a conversation command such as `/compact`, by item id. */ +export function structuredAgentSessionCommandItemIds( + items: readonly AgentJournalRenderItem[] +): Set { + return new Set( + items + .filter((item) => item.body.kind === 'message' && item.body.command) + .map((item) => item.itemId) + ) +} + /** * The rejected submissions the host's history shows in place as not sent, by item id; `submissions` - * in submission order, as the client keeps them. A withdrawn one went back to its sender, and one - * the queue holds (a draft's hand-off, or a card under its id) is drawn as its card. + * in submission order, as the client keeps them. A withdrawn one went back to its sender, one the + * queue holds (a draft's hand-off, or a card under its id) is drawn as its card, and a command such + * as `/compact` has its rejection reported as its own reply. */ export function structuredAgentSessionRejectedShownInPlace( submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[] + queuedMessageIds: readonly string[], + 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 @@ -49,6 +62,7 @@ export function structuredAgentSessionRejectedShownInPlace( dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined || cards.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 // was known counts, so a repeat sent before it is kept. @@ -79,13 +93,16 @@ export function projectStructuredAgentSessionMessages( .map((submission) => agentJournalSubmissionKey(submission.clientMessageId)) ) const inPlace = options.rejectedInPlace - ? structuredAgentSessionRejectedShownInPlace(submissions, options.queuedMessageIds ?? []) + ? structuredAgentSessionRejectedShownInPlace( + submissions, + options.queuedMessageIds ?? [], + structuredAgentSessionCommandItemIds(items) + ) : new Set() const visibleItems: AgentJournalRenderItem[] = [] const unsentItems: AgentJournalRenderItem[] = [] for (const item of items) { - // A command such as `/compact` has its rejection reported as its own reply, so it stays hidden. - if (inPlace.has(item.itemId) && !(item.body.kind === 'message' && item.body.command)) { + if (inPlace.has(item.itemId)) { unsentItems.push(item) } else if (!rejected.has(item.itemId)) { visibleItems.push(item) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index f1c8a58cd68e..ad6ed8ed7d3a 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -15,7 +15,9 @@ import { structuredAgentSessionPayloadFingerprint } from './structured-agent-ses import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `rejected`: settled as not delivered. The drain never sends it again and nothing queues behind - * it; only the user's Retry does. One the host recorded leaves once the journal's row shows it. */ + * it. One the host refused unrecorded waits for the user's Retry. One it recorded leaves once the + * client holds its rejected submission: the host places that row at the rejection, so the newest + * page carries it, and until then the user may Dismiss it. */ export type StructuredAgentSessionOutboxState = | 'queued' | 'dispatching' @@ -173,8 +175,8 @@ export function structuredAgentSessionEntryIdExpired( ) } -/** The host recorded this send and then rejected it: the reconcile drops it once the journal's - * submissions say so, and its row is the message from then on. */ +/** The host recorded this send and then rejected it: no Retry, since sending it again is a new + * message. The reconcile drops it once the client holds the rejected submission. */ export function structuredAgentSessionEntryRejectedByHost( entry: StructuredAgentSessionOutboxEntry ): boolean { From 9681cbb09fe7370be255f0e9bc965d5c9a69de48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:20:26 -0700 Subject: [PATCH 13/19] fix(native-chat): write no start-failure row when a Stop withdrew every queued message first --- .../journal-lifecycle-batch-appender.ts | 20 ++++++++++++------- .../journal-queued-rejection-batch.test.ts | 18 +++++++++++++++++ .../journal-store-contracts.ts | 3 ++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts index ef4309347ba5..68ba188e4021 100644 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.ts @@ -22,21 +22,27 @@ export class JournalLifecycleBatchAppender { append(input: JournalLifecycleBatchInput): Promise { const { rejectsQueued } = input if (rejectsQueued) { - // Planned on the lane: the sends queued then, and this batch unless it already landed. + // Planned on the lane: the sends queued then, and this batch unless it already landed. With + // none left (a Stop withdrew them first) it failed no one, so nothing is written. return this.deps - .enqueueRows(() => [ - ...journalQueuedRejectionRowBuilders(this.deps.state, input.fence, rejectsQueued), - ...(this.wasApplied(input.settlementId) - ? [] + .enqueueRows(() => { + const rejections = journalQueuedRejectionRowBuilders( + this.deps.state, + input.fence, + rejectsQueued + ) + return rejections.length === 0 || this.wasApplied(input.settlementId) + ? rejections : [ + ...rejections, journalLifecycleBatchRowBuilder( this.deps.state, input.settlementId, input.mutations, input ) - ]) - ]) + ] + }) .then(() => this.deps.cursor()) } if (this.wasApplied(input.settlementId)) { diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts index 6efc197ae30b..d218a356fae5 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts @@ -109,3 +109,21 @@ it('writes neither when the row cannot be written', async () => { expect(journal.cursor().sequence).toBe(before) expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) }) + +// A Stop that reaches the lane first takes the message back; the failed start then failed no one. +it('writes nothing when a Stop withdrew every queued message first', async () => { + const journal = await openWithQueued('first') + const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + + await Promise.all([ + journal.rejectQueuedSubmissions(0, withdrawal), + journal.appendLifecycleBatch(startFailureBatch()) + ]) + + expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('first') + ]) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index cc18885b7062..cb86a7a8e5da 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -71,7 +71,8 @@ export type JournalLifecycleBatchInput = { fence: number recovered?: true /** Rejects the sends still queued with this first, in the same append: a failed start's row - * follows the messages it failed, and no reader meets one without the other. */ + * follows the messages it failed, and no reader meets one without the other. With none still + * queued, the batch is not written either. */ rejectsQueued?: AgentJournalDispatchRejection } From c4b31ab27a75cd39aa11434ec21abd5df9ed0526 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:22:01 -0700 Subject: [PATCH 14/19] test(native-chat): pass the Dismiss action in the delivery-notice hook tests --- .../use-structured-agent-session-delivery-notices.test.tsx | 2 ++ 1 file changed, 2 insertions(+) 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 28ef88564b90..44dcd439547a 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 @@ -67,6 +67,7 @@ it('keeps the same notices across batches in a chat whose only rejection a Stop failedHere: NONE, queuedMessageIds: NO_CARDS, retry: () => {}, + dismiss: () => {}, agentName: 'Claude' }), { initialProps: { submissions: [withdrawn('stopped')] } } @@ -89,6 +90,7 @@ function renderNotices(submissions: readonly AgentJournalSubmission[]) { failedHere: NONE, queuedMessageIds: NO_CARDS, retry: () => {}, + dismiss: () => {}, agentName: 'Claude' }), { initialProps: { submissions } } From 33fc5771ae565c2dbc94d125ac8fc49c3f6f2c3f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:57:56 -0700 Subject: [PATCH 15/19] fix(native-chat): keep a rejected message's outbox copy until its row loads An older host leaves a rejected message where it was sent, which may be older than the loaded window: the chat then holds the rejected submission but not the row that draws it. The outbox copy now stays until that row loads, marked as the host recorded it (Dismiss, no Retry, in the host's words), and leaves once the page holding the row is loaded. Derived from the loaded rows each time. Tests that label their projection as the phone's now pass the phone's own setting. --- .../codex-structured-question-order.test.ts | 2 +- .../journal-turn-scope.test.ts | 2 +- ...tiveChatStructuredSession.test-harness.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 2 + ...ructured-agent-session-delivery-notices.ts | 17 +- ...sion-message-projection.older-host.test.ts | 163 ++++++++++++++++++ ...ssage-projection.rejected-in-place.test.ts | 7 +- ...session-withdrawn-message-restore.test.tsx | 14 +- ...ed-agent-session-outbox-admission.test.tsx | 10 +- ...ctured-agent-session-outbox-fence.test.tsx | 5 + ...agent-session-outbox-owner-change.test.tsx | 5 + ...nt-session-outbox-rejection-cause.test.tsx | 140 ++++++++++++++- ...gent-session-outbox-relaunch-hold.test.tsx | 7 + ...d-agent-session-outbox-withdrawal.test.tsx | 9 +- ...ent-session-outbox.draft-hand-off.test.tsx | 8 +- ...gent-session-outbox.external-send.test.tsx | 5 + ...ent-session-outbox.queue-delivery.test.tsx | 9 + ...ent-session-outbox.rejected-reply.test.tsx | 3 + ...ession-outbox.stop-parks-in-doubt.test.tsx | 7 + ...e-structured-agent-session-outbox.test.tsx | 85 ++++----- .../use-structured-agent-session-outbox.ts | 12 +- .../use-structured-agent-session.ts | 1 + ...tured-agent-session-draft-hand-off.test.ts | 4 +- ...structured-agent-session-draft-hand-off.ts | 8 +- ...ctured-agent-session-message-projection.ts | 2 +- ...ed-agent-session-outbox-retry-hold.test.ts | 6 +- src/shared/structured-agent-session-outbox.ts | 33 +++- ...red-agent-session-send-disposition.test.ts | 8 +- 28 files changed, 489 insertions(+), 86 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 1a856b9ead46..78c197644e2e 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -245,7 +245,7 @@ describe('a Codex ask with several questions', () => { // Mobile draws the shared projection in journal order, one row per question. expect( projectStructuredAgentSessionMessages(client.items, [], client.submissions, { - rejectedInPlace: true + rejectedInPlace: false }).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null)) ).toEqual(ASKED.map(({ question }) => question)) }) diff --git a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts index be7a3d7d70f5..4674f6de9296 100644 --- a/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-turn-scope.test.ts @@ -164,7 +164,7 @@ describe('stated turn scope', () => { const onPhone = () => { const { items, submissions } = renderJournalState(state) return projectStructuredAgentSessionMessages(items, [], submissions, { - rejectedInPlace: true + rejectedInPlace: false }) } // Still waiting: drawn after everything the agent did, the command's result included. diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index bec1ffc98aab..d522e1860043 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -140,6 +140,7 @@ export function createStructuredSessionMocks() { }) => { mocks.controllerProps = props const outbox = useStructuredAgentSessionOutbox({ + journalItems: mocks.journalItems, sessionId: props.sessionId, target: props.target, fence: props.transportEnabled === false ? null : 1, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 986c3f51cfb9..abbda32b7ba9 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ }, questionCardProps: null as NativeChatQuestionCardProps | null, promptItems: [] as AgentJournalRenderItem[], + noJournalItems: Array.of(), respond: vi.fn(), handlePasteEvent: vi.fn(), pasteFromClipboard: vi.fn(), @@ -53,6 +54,7 @@ vi.mock('./use-structured-agent-session', async () => { target: { kind: 'local' } | { kind: 'environment'; environmentId: string } }) => { const outbox = useStructuredAgentSessionOutbox({ + journalItems: mocks.noJournalItems, sessionId: props.sessionId, target: props.target, fence: 1, 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 e15010ab87d7..63df80175646 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 @@ -192,16 +192,27 @@ export function structuredAgentSessionDeliveryNotices( ) } } - // After the outbox's: the host's row is the message, whatever the outbox still holds under its id. + // After the outbox's, in the host's words. The outbox copy draws it while the row is not loaded + // (an older host may leave that row outside the window), so its Dismiss stays until it leaves. const shown = structuredAgentSessionRejectedShownInPlace( submissions, queuedMessageIds, commandItemIds ) + const outboxDraws = new Set( + outbox.filter(structuredAgentSessionEntryRejectedByHost).map((entry) => entry.clientMessageId) + ) for (const submission of rejected.values()) { - const id = agentJournalSubmissionKey(submission.clientMessageId) + const { clientMessageId } = submission + const id = agentJournalSubmissionKey(clientMessageId) if (shown.has(id)) { - notices.set(id, { text: hostRejectionNoticeText(submission, agentName, startFailures) }) + const text = hostRejectionNoticeText(submission, agentName, startFailures) + notices.set( + id, + outboxDraws.has(clientMessageId) + ? { text, onDismiss: () => dismiss(clientMessageId) } + : { text } + ) } } return notices diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts new file mode 100644 index 000000000000..e29fec619b6c --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts @@ -0,0 +1,163 @@ +// An older host leaves a rejected message where it was sent. When that is older than the loaded +// window, the chat holds its rejected submission but not its row: the outbox copy keeps drawing it, +// with a Dismiss, until the page holding the row loads, and then the host's row draws it instead. + +import { expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from '../../../../shared/structured-agent-session-reducer' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const NO_CARDS: readonly string[] = [] +const MESSAGE_ID = agentJournalSubmissionKey('m') + +function answer(sequence: number): AgentJournalRenderItem { + return { + itemId: `a-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +/** Where the older host left it: at its submission, far behind the loaded window. */ +const SENT: AgentJournalRenderItem = { + itemId: MESSAGE_ID, + revision: 1, + sequence: 10, + observedAt: 10, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'queued msg' }] } +} + +const REJECTED: AgentJournalSubmission = { + clientMessageId: 'm', + fence: 1, + payloadFingerprint: 'x', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' }, + submittedAt: 10, + resolvedAt: 2000 +} + +function page( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[], + hasOlder: boolean +): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? 0 + return { + sessionId: 's', + epoch: 'e', + direction: 'tail', + items, + removedItemIds: [], + submissions, + window: { + oldest: { epoch: 'e', sequence: oldest }, + newest: { epoch: 'e', sequence: newest }, + nextCursor: { epoch: 'e', sequence: oldest } + }, + liveCursor: { epoch: 'e', sequence: 1099 }, + hasOlder, + hasNewer: false + } +} + +it("keeps the outbox copy of a rejected message whose row is outside the window, then the host's row takes over", () => { + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 's', + fence: 1, + page: page( + Array.from({ length: 100 }, (_, index) => answer(1000 + index)), + [], + true + ) + } + }) + // The rejection touches the row, which the older host left at its submission. + state = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 's', + batch: { + cursor: { epoch: 'e', sequence: 1100 }, + items: [SENT], + removedItemIds: [], + submissions: [REJECTED] + } + } + }) + expect(state.items.some((item) => item.itemId === MESSAGE_ID)).toBe(false) + expect(state.submissions.map((submission) => submission.clientMessageId)).toEqual(['m']) + + const sent = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'm', + sessionId: 's', + text: 'queued msg', + attachments: [], + queuedAt: 10 + }), + state: 'dispatching' as const + } + const kept = reconcileStructuredAgentSessionOutboxWithQueue( + [sent], + state.submissions, + state.items + ) + expect(kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) + const drawn = (outbox: typeof kept) => + projectStructuredAgentSessionMessages(state.items, outbox, state.submissions, NO_CARDS) + .filter((message) => message.role === 'user') + .map(({ id, unsent }) => ({ id, unsent })) + expect(drawn(kept)).toEqual([{ id: MESSAGE_ID, unsent: true }]) + const notice = structuredAgentSessionDeliveryNotices( + kept, + 'Claude', + () => {}, + () => {}, + state.submissions, + [], + new Set() + ).get(MESSAGE_ID) + expect(notice?.onDismiss).toBeDefined() + expect(notice?.onRetry).toBeUndefined() + + // Paging back loads the row: the outbox lets go, and the host's row is the one drawn. + state = reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor: { epoch: 'e', sequence: 1000 }, + page: page( + [SENT, ...Array.from({ length: 10 }, (_, index) => answer(990 + index))], + [REJECTED], + false + ) + }) + expect(state.items.some((item) => item.itemId === MESSAGE_ID)).toBe(true) + const released = reconcileStructuredAgentSessionOutboxWithQueue( + kept, + state.submissions, + state.items + ) + expect(released).toEqual([]) + expect(drawn(released)).toEqual([{ id: MESSAGE_ID, unsent: true }]) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 1853f521fe71..75946b80a362 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -379,9 +379,10 @@ describe("one row per rejected message, the host's once it records the rejection [], new Set() ) - expect([...notices]).toEqual([ - [hostRow.id, { text: 'Orca restarted before this message was sent.' }] - ]) + // In the host's words, never a Retry; a recorded copy keeps its Dismiss until it leaves. + expect([...notices.keys()]).toEqual([hostRow.id]) + expect(notices.get(hostRow.id)?.text).toBe('Orca restarted before this message was sent.') + expect(notices.get(hostRow.id)?.onRetry).toBeUndefined() } }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 0ff37b66540e..b36279a4a92a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -75,6 +75,8 @@ import { import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { useStructuredAgentSession } from './use-structured-agent-session' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const SESSION = 'session-1' const PANE = 'tab-1::session-1' const OTHER_PANE = 'tab-2::session-1' @@ -129,8 +131,9 @@ function answerSendsPending(): void { function renderOutbox(composerScopeKey: string | null = PANE) { return renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: SESSION, target, fence: 1, @@ -267,6 +270,15 @@ describe('a message the host withdrew at a Stop', () => { rerender({ submissions: [ submission(id, { dispatchState: 'rejected', reason: DISPATCH_REJECTED_WRITE_FAILED }) + ], + rows: [ + { + itemId: `orca:${id}`, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } ] }) 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 bffb7ed01cdd..b6b7e2318a8e 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 @@ -7,7 +7,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' const mocks = vi.hoisted(() => ({ @@ -26,6 +29,8 @@ import { agentJournalSubmissionKey } from '../../../../shared/agent-session-jour import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { structuredAgentSessionEntryHeldForRetry } from '../../../../shared/structured-agent-session-outbox-admission' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const LOCAL_TARGET = { kind: 'local' } as const function deferred() { @@ -93,6 +98,7 @@ function refusedResult(code: AgentSessionWireRefusalCode) { function renderOutbox() { return renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -229,6 +235,7 @@ describe('structured agent session outbox admission', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -311,6 +318,7 @@ describe('structured agent session outbox admission', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx index e9332e6ef1e8..c9b36731c575 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx @@ -20,6 +20,10 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-sessio import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -65,6 +69,7 @@ function render(fence: number | null = 1) { return renderHook( (props) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: props.fence, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx index 926d77d47943..fcc0d2a6c620 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-owner-change.test.tsx @@ -34,6 +34,10 @@ import { import { writeOutbox } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + type SendRequest = { envelope?: { clientOperationId?: string; expectedRuntimeFence?: number } } // Stable, as the view passes it: a new object each render would re-run the owner-change requeue. @@ -82,6 +86,7 @@ function mount(fence: number) { return renderHook( ({ fence: current }: { fence: number }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: current, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 01af0ddcfbcd..d634e2d39713 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -2,7 +2,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' const mocks = vi.hoisted(() => ({ @@ -30,6 +33,8 @@ import { writeOutbox } from './structured-agent-session-outbox-storage' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -104,6 +109,7 @@ describe('a send the host rejected because the agent never started', () => { ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -132,6 +138,7 @@ describe('a send the host rejected because the agent never started', () => { }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -168,8 +175,9 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, @@ -183,7 +191,10 @@ describe('a send the host rejected because the agent never started', () => { const id = result.current.outbox[0]!.clientMessageId rerender({ - submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + submissions: [ + { ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason } + ], + rows: rowsFor(id) }) // The host's row shows it as not sent, with no Retry: nothing resends it. @@ -203,8 +214,9 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, @@ -243,6 +255,7 @@ describe('a send the host rejected because the agent never started', () => { const target = { kind: 'local' } as const const first = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, @@ -258,8 +271,10 @@ describe('a send the host rejected because the agent never started', () => { const rejected = [ { ...pendingResultFor(id).value.submission, dispatchState: 'rejected' as const, reason } ] + const reopenedRows = rowsFor(id) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: reopenedRows, sessionId: 'session-1', target, fence: 1, @@ -303,6 +318,7 @@ describe('a send the host rejected because the agent never started', () => { const mount = () => renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -334,8 +350,9 @@ describe('a send the host rejected because the agent never started', () => { it('drops a message rejected before a restart once the journal says so, from storage too', async () => { writeOutbox('session-1', [rejectedBeforeRestart()]) const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -353,7 +370,8 @@ describe('a send the host rejected because the agent never started', () => { reason: 'The provider did not accept this message.', resolvedAt: 2 } - ] + ], + rows: rowsFor('rejected-before-restart') }) await waitFor(() => expect(result.current.outbox).toEqual([])) @@ -362,6 +380,94 @@ describe('a send the host rejected because the agent never started', () => { expect(mocks.call).not.toHaveBeenCalled() }) + // An older host leaves a rejected message where it was sent, which may be outside the loaded + // window: the entry draws it, with a Dismiss, until the row that draws it loads. + it('keeps a rejected message whose row is not loaded, then lets it go once the row loads', async () => { + writeOutbox('session-1', [ + { ...rejectedBeforeRestart(), state: 'dispatching', lastFailure: undefined, lastAttemptAt: 5 } + ]) + const rejected = [ + { + ...pendingResultFor('rejected-before-restart').value.submission, + dispatchState: 'rejected' as const, + reason: REASON, + resolvedAt: 2 + } + ] + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: rejected } } + ) + + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) + expect(shownFailure(result.current.outbox[0])).toBe(REASON) + expect(readOutbox('session-1')).toHaveLength(1) + + rerender({ submissions: rejected, rows: rowsFor('rejected-before-restart') }) + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readOutbox('session-1')).toEqual([]) + expect(mocks.call).not.toHaveBeenCalled() + }) + + it('keeps a send its reply rejected without a Retry until the journal row takes it over', async () => { + const writeFailed = (clientMessageId: string): AgentJournalSubmission => ({ + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'provider_write_failed: broken pipe', + submittedAt: 10, + resolvedAt: 10 + }) + mocks.call.mockImplementationOnce( + async ( + _target: unknown, + _method: unknown, + params: { envelope: { clientOperationId: string } } + ) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 10 }, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: writeFailed(params.envelope.clientOperationId) + } + }) + ) + const { result, rerender } = renderHook( + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: props.submissions + }), + { initialProps: { submissions: NO_SUBMISSIONS } } + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + const firstId = String(mocks.call.mock.calls[0]![2].envelope.clientOperationId) + + // Answered, not doubted: the entry draws the message, saying why, until the journal has it. + await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected')) + expect(result.current.outbox[0]?.state).toBe('rejected') + + rerender({ submissions: [writeFailed(firstId)], rows: rowsFor(firstId) }) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + expect(mocks.call).toHaveBeenCalledOnce() + }) + it('lets the journal settle the message when its rejection lands before the send answers', async () => { const reason = "Codex couldn't restart: spawn codex ENOENT." let answer: (value: unknown) => void = () => undefined @@ -373,8 +479,9 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[] }) => + (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => useStructuredAgentSessionOutbox({ + journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', target, fence: 1, @@ -389,7 +496,10 @@ describe('a send the host rejected because the agent never started', () => { // A start refused at once: the rejection frame lands before the send's own `pending` answer. rerender({ - submissions: [{ ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason }] + submissions: [ + { ...pendingResultFor(id).value.submission, dispatchState: 'rejected', reason } + ], + rows: rowsFor(id) }) await waitFor(() => expect(result.current.outbox).toEqual([])) // The late `pending` answer must not bring it back. @@ -402,6 +512,17 @@ describe('a send the host rejected because the agent never started', () => { const NO_SUBMISSIONS: AgentJournalSubmission[] = [] +/** The host's rows for these messages, loaded. */ +function rowsFor(...ids: string[]): AgentJournalRenderItem[] { + return ids.map((id, index) => ({ + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + })) +} + function rejectedBeforeRestart(): StructuredAgentSessionOutboxEntry { return { ...createStructuredAgentSessionOutboxEntry({ @@ -490,6 +611,7 @@ describe('a send refused while its agent restarted', () => { const { result, rerender } = renderHook( ({ fence, submissions }: { fence: number; submissions: AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence, @@ -549,6 +671,7 @@ describe('a send the host refused by throwing', () => { ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, @@ -589,6 +712,7 @@ describe('a send refused on a journal a newer Orca wrote', () => { }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index 75dac8b22904..aeac5625a4b5 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -28,6 +28,10 @@ import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/stru import { writeOutbox } from './structured-agent-session-outbox-storage' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const SESSION = 'session-1' // Stable, as the view passes it: a new object each render would re-run the owner-change requeue. const LOCAL_TARGET = { kind: 'local' } as const @@ -96,6 +100,7 @@ function mount(fence = 1) { return renderHook( ({ fence: current }: { fence: number }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: SESSION, target: LOCAL_TARGET, fence: current, @@ -426,6 +431,7 @@ describe('a message whose send could not be saved before it went out', () => { const { result, rerender } = renderHook( ({ fence }: { fence: number | null }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: SESSION, target: LOCAL_TARGET, fence, @@ -544,6 +550,7 @@ describe('a held message whose id expired', () => { mocks.call.mockResolvedValue(expired()) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: SESSION, target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx index 1c7d69f1aa70..4e815c9252f0 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-withdrawal.test.tsx @@ -5,7 +5,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { hasUnsentStructuredAgentSessionOutboxEntry, @@ -25,6 +28,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { readOutbox } from './structured-agent-session-outbox-storage' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // One object: a target rebuilt each render reads as a new owner, which re-sends what is on its way. const TARGET = { kind: 'local' } as const @@ -82,6 +87,7 @@ describe('a Stop withdrawing what the host does not hold', () => { ) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, @@ -147,6 +153,7 @@ describe('a Stop withdrawing what the host does not hold', () => { mocks.call.mockRejectedValue(new Error('the host refused the frame')) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx index ff13958f160c..61fc0685816e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.draft-hand-off.test.tsx @@ -7,7 +7,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { DISPATCH_REJECTED_CANCELLED } from '../../../../shared/structured-agent-session-dispatch-rejection' import { @@ -28,6 +31,8 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { writeOutbox } from './structured-agent-session-outbox-storage' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -77,6 +82,7 @@ function renderOutbox() { return renderHook( (props: Props) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx index 95d9021413a1..0cb70aab14b3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.external-send.test.tsx @@ -15,6 +15,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { appendStructuredAgentSessionOutboxMessage } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + afterEach(cleanup) beforeEach(() => { @@ -39,6 +43,7 @@ it('delivers a message queued from outside the chat through the open outbox', as }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx index 26e0fd71112b..fadb44405340 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsx @@ -31,6 +31,10 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { readOutbox } from './structured-agent-session-outbox-storage' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -53,6 +57,7 @@ function queuedReceipt(clientMessageId: string) { function renderOutbox(queue: boolean) { return renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -154,6 +159,7 @@ describe('outbox queue delivery selection', () => { const first = renderHook( (props: { queuedMessageIds: string[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -184,6 +190,7 @@ describe('outbox queue delivery selection', () => { const view = renderHook( (props: { queuedMessageIds: string[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -252,6 +259,7 @@ describe('outbox queue delivery selection', () => { })) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -306,6 +314,7 @@ async function attemptedQueueSend() { const view = renderHook( (props: { capability: StructuredAgentSessionQueueCapability }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx index 6c1ffac40856..bfebc877b2c8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.rejected-reply.test.tsx @@ -29,6 +29,8 @@ import { import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + const NO_CARDS: readonly string[] = [] afterEach(cleanup) @@ -64,6 +66,7 @@ it('draws a send its reply rejected in place once, and leaves the composer empty }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: { kind: 'local' }, fence: 1, diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx index 293d9719bc0f..45d87f26f666 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.stop-parks-in-doubt.test.tsx @@ -25,6 +25,10 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -66,6 +70,7 @@ describe('a Stop with a queued send in doubt', () => { const view = renderHook( (props: { fence: number | null }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: REMOTE, fence: props.fence, @@ -110,6 +115,7 @@ describe('a Stop with a queued send in doubt', () => { mocks.call.mockImplementationOnce(() => answer.promise) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, @@ -170,6 +176,7 @@ describe('a Stop with a queued send in doubt', () => { ]) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: TARGET, fence: 1, 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 bf25789d01b9..d8eabc294d21 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 @@ -4,7 +4,10 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { useLayoutEffect } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' @@ -19,6 +22,16 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +const hostRow = (id: string): AgentJournalRenderItem => ({ + itemId: `orca:${id}`, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } +}) + +const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] + // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. afterEach(cleanup) @@ -149,6 +162,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ fence }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence, @@ -188,6 +202,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -209,6 +224,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ fence }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence, @@ -243,6 +259,7 @@ describe('useStructuredAgentSessionOutbox', () => { mocks.call.mockResolvedValueOnce(refusedResult(code)).mockResolvedValueOnce(acceptedResult(1)) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -275,6 +292,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -314,6 +332,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -344,6 +363,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -373,6 +393,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -398,6 +419,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -429,6 +451,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) const first = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -443,6 +466,7 @@ describe('useStructuredAgentSessionOutbox', () => { const restored = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -474,6 +498,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -513,6 +538,7 @@ describe('useStructuredAgentSessionOutbox', () => { .mockResolvedValueOnce(acceptedResult(1)) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -542,6 +568,7 @@ describe('useStructuredAgentSessionOutbox', () => { }) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -579,6 +606,7 @@ describe('useStructuredAgentSessionOutbox', () => { mocks.call.mockResolvedValue(acceptedResult(1)) const { result } = renderHook(() => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -626,6 +654,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -677,9 +706,11 @@ describe('useStructuredAgentSessionOutbox', () => { .clientOperationId return acceptedResultFor(clientMessageId, 11) }) + let rows = NO_JOURNAL_ITEMS const { result, rerender } = renderHook( ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => useStructuredAgentSessionOutbox({ + journalItems: rows, sessionId: 'session-1', target: LOCAL_TARGET, fence: 1, @@ -692,6 +723,7 @@ 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)) + rows = [hostRow(firstId)] rerender({ submissions: [ { @@ -716,54 +748,6 @@ describe('useStructuredAgentSessionOutbox', () => { }) }) - it('keeps a send its reply rejected without a Retry until the journal row takes it over', async () => { - const writeFailed = (clientMessageId: string): AgentJournalSubmission => ({ - clientMessageId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - reason: 'provider_write_failed: broken pipe', - submittedAt: 10, - resolvedAt: 10 - }) - mocks.call.mockImplementationOnce(async (_target, _method, params) => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 10 }, - value: { - clientMessageId: (params as { envelope: { clientOperationId: string } }).envelope - .clientOperationId, - submission: writeFailed( - (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId - ) - } - })) - const { result, rerender } = renderHook( - ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => - useStructuredAgentSessionOutbox({ - sessionId: 'session-1', - target: LOCAL_TARGET, - fence: 1, - submissions - }), - { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } - ) - - act(() => expect(result.current.send('first')).toBe(true)) - await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) - const firstId = mocks.call.mock.calls[0]![2].envelope.clientOperationId as string - - // Answered, not doubted: the entry draws the message, saying why, until the journal has it. - await waitFor(() => expect(result.current.outbox[0]?.lastFailure?.kind).toBe('rejected')) - expect(result.current.outbox[0]?.state).toBe('rejected') - - rerender({ submissions: [writeFailed(firstId)] }) - await waitFor(() => expect(result.current.outbox).toHaveLength(0)) - expect(mocks.call).toHaveBeenCalledOnce() - }) - 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 @@ -774,6 +758,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ sessionId }: { sessionId: string }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId, target: LOCAL_TARGET, fence: 1, @@ -806,6 +791,7 @@ describe('useStructuredAgentSessionOutbox', () => { const { result, rerender } = renderHook( ({ sessionId }: { sessionId: string }) => useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId, target: LOCAL_TARGET, fence: 1, @@ -835,6 +821,7 @@ describe('useStructuredAgentSessionOutbox', () => { } = { current: null } function Probe({ sessionId }: { sessionId: string }): null { controllerRef.current = useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, sessionId, target: LOCAL_TARGET, fence: 1, 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 89693455126d..f538e37fef0c 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 @@ -6,7 +6,10 @@ import { useState, useSyncExternalStore } from 'react' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { admitStructuredAgentSessionOutboxEntry, @@ -61,6 +64,8 @@ export function useStructuredAgentSessionOutbox(args: { target: RuntimeClientTarget fence: number | null submissions: readonly AgentJournalSubmission[] + /** The loaded journal rows: a rejected message stays here until the row that draws it loads. */ + journalItems: readonly AgentJournalRenderItem[] /** The composer that gets back what a Stop withdrew from this client's outbox. */ composerScopeKey?: string /** The host's queued-messages capability and the user's setting; a send stamped @@ -74,6 +79,7 @@ export function useStructuredAgentSessionOutbox(args: { const { composerScopeKey, fence, + journalItems, queueDelivery = NO_QUEUE_DELIVERY, queuedMessageIds, sessionId, @@ -145,7 +151,7 @@ export function useStructuredAgentSessionOutbox(args: { .map((submission) => submission.clientMessageId), ...handedOffQueuedMessageIds(submissions) ]) - const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions) + const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions, journalItems) const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current) if ( admittedInFlight || @@ -172,7 +178,7 @@ export function useStructuredAgentSessionOutbox(args: { ) { setError(null) } - }, [restoreWithdrawn, sessionId, submissions]) + }, [journalItems, restoreWithdrawn, sessionId, submissions]) // The one place that owns the refs, the React state and the storage write. const applyDisposition = useCallback( 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 562eea0aa62b..7382ae360add 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 @@ -120,6 +120,7 @@ export function useStructuredAgentSession(args: { target, fence: transportState.fence, submissions: transportState.submissions, + journalItems: transportState.journalItems, composerScopeKey, queueDelivery: { capability: queueCapability, enabled: queueFollowUps }, queuedMessageIds diff --git a/src/shared/structured-agent-session-draft-hand-off.test.ts b/src/shared/structured-agent-session-draft-hand-off.test.ts index 6e0cbc7e644f..1b42e1a93a1e 100644 --- a/src/shared/structured-agent-session-draft-hand-off.test.ts +++ b/src/shared/structured-agent-session-draft-hand-off.test.ts @@ -44,7 +44,9 @@ function handOff(dispatchState: AgentJournalSubmission['dispatchState']): AgentJ describe('a queued draft handed off under a fresh submission id', () => { it('takes the outbox entry off the client, in every dispatch state', () => { for (const state of ['pending', 'accepted', 'rejected', 'unknown'] as const) { - expect(reconcileStructuredAgentSessionOutboxWithQueue([entry], [handOff(state)])).toEqual([]) + expect(reconcileStructuredAgentSessionOutboxWithQueue([entry], [handOff(state)], [])).toEqual( + [] + ) } }) diff --git a/src/shared/structured-agent-session-draft-hand-off.ts b/src/shared/structured-agent-session-draft-hand-off.ts index 29a141590381..09a4545779ac 100644 --- a/src/shared/structured-agent-session-draft-hand-off.ts +++ b/src/shared/structured-agent-session-draft-hand-off.ts @@ -2,7 +2,7 @@ // under a fresh submission id, so this link, never a draft id compared with a `clientMessageId`, // is how a client knows the host has taken a message over. -import type { AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' import { reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry @@ -29,13 +29,15 @@ export function handedOffQueuedMessageIds( */ export function reconcileStructuredAgentSessionOutboxWithQueue( entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + items: readonly AgentJournalRenderItem[] ): StructuredAgentSessionOutboxEntry[] { const handedOff = handedOffQueuedMessageIds(submissions) return reconcileStructuredAgentSessionOutbox( handedOff.size === 0 ? entries : entries.filter((entry) => !handedOff.has(entry.clientMessageId)), - submissions + submissions, + items ) } diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index c0af9ac8cb3a..57a2f6fe15d6 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -85,7 +85,7 @@ export function projectStructuredAgentSessionMessages( options: StructuredAgentSessionMessageProjectionOptions, projectItems = projectStructuredItemsToNativeChat ): NativeChatMessage[] { - const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions) + const optimistic = reconcileStructuredAgentSessionOutboxWithQueue(outbox, submissions, items) // Refused sends are ledger evidence, not conversation history, unless drawn in place as not sent. const rejected = new Set( submissions 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..f894fe27de04 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -71,7 +71,8 @@ describe('a message held for its Retry', () => { } const [landed] = reconcileStructuredAgentSessionOutbox( [entry('held', { lastFailure: REFUSED })], - [submission] + [submission], + [] ) expect(landed?.state).toBe('dispatching') expect(landed?.lastFailure).toBeUndefined() @@ -91,7 +92,8 @@ describe('a message held for its Retry', () => { } const reconciled = reconcileStructuredAgentSessionOutbox( [entry('held', { lastAttemptAt: 2, lastFailure: REFUSED }), entry('next')], - [submission] + [submission], + [] ) expect(reconciled[0]).toMatchObject({ state: 'unconfirmed' }) expect(reconciled[0]?.lastFailure).toBeUndefined() diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index ad6ed8ed7d3a..66aad2745b5a 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -1,6 +1,11 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import { readWholeAgentSessionFailureFact } from './agent-session-failure' -import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' import { parseAgentSessionWriteFailure, type AgentSessionWriteFailure, @@ -11,6 +16,7 @@ import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' @@ -217,16 +223,33 @@ export function requeueStructuredAgentSessionSendRefusal( export function reconcileStructuredAgentSessionOutbox( entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[] + submissions: readonly AgentJournalSubmission[], + /** The loaded journal rows: a rejected message leaves only once the row that draws it is here. */ + items: readonly AgentJournalRenderItem[] ): StructuredAgentSessionOutboxEntry[] { const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) + let loaded: Set | undefined return entries.flatMap((entry) => { const submission = settled.get(entry.clientMessageId) - // Settled by the host: its history shows one delivered or rejected; a withdrawn one goes back - // to the composer. - if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'rejected') { + // Settled by the host: its history shows one delivered; a withdrawn one goes back to the + // composer. + if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { return [] } + if (submission?.dispatchState === 'rejected') { + // Its row draws it once loaded; until then the entry does, as the host recorded it. An older + // host leaves that row where it was sent, which may be outside the loaded window. + loaded ??= new Set(items.map((item) => item.itemId)) + if (loaded.has(agentJournalSubmissionKey(entry.clientMessageId))) { + return [] + } + const lastFailure = structuredAgentSessionRejectedFailure(submission) + return [ + structuredAgentSessionEntryRejectedByHost(entry) + ? entry + : { ...entry, state: 'rejected' as const, lastFailure } + ] + } if (submission?.dispatchState === 'pending') { if (entry.state === 'dispatching') { return [entry] diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index 98ea2ff8b676..f3916e1b9035 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -120,7 +120,9 @@ describe('what a rejection shows the user', () => { throw new Error('expected rejected submission fixture') } - expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission], [])).toEqual( + [] + ) }) it('never puts the transport marker on screen', () => { @@ -168,7 +170,9 @@ describe('what a rejection shows the user', () => { if (!result.ok || !('submission' in result.value)) { throw new Error('expected rejected submission fixture') } - expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([]) + expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission], [])).toEqual( + [] + ) expect( disposeStructuredAgentSessionSendResult({ entries: [entry], From 50f8525c263364526c343c9a9474107b4faaf58d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:00:22 -0700 Subject: [PATCH 16/19] test(native-chat): type the outbox hook props that carry loaded rows --- ...session-withdrawn-message-restore.test.tsx | 11 +++++-- ...nt-session-outbox-rejection-cause.test.tsx | 31 ++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index b36279a4a92a..dde230110dd2 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -75,6 +75,13 @@ import { import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { useStructuredAgentSession } from './use-structured-agent-session' +/** What these hooks render with: the journal's submissions, and the rows loaded so far. */ +type OutboxProps = { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] } + +function outboxProps(submissions: AgentJournalSubmission[]): OutboxProps { + return { submissions } +} + const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] const SESSION = 'session-1' @@ -131,7 +138,7 @@ function answerSendsPending(): void { function renderOutbox(composerScopeKey: string | null = PANE) { return renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: SESSION, @@ -140,7 +147,7 @@ function renderOutbox(composerScopeKey: string | null = PANE) { submissions: props.submissions, ...(composerScopeKey ? { composerScopeKey } : {}) }), - { initialProps: { submissions: NONE } } + { initialProps: outboxProps(NONE) } ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index d634e2d39713..90beed6e96f3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -33,6 +33,13 @@ import { writeOutbox } from './structured-agent-session-outbox-storage' +/** What these hooks render with: the journal's submissions, and the rows loaded so far. */ +type OutboxProps = { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] } + +function outboxProps(submissions: AgentJournalSubmission[]): OutboxProps { + return { submissions } +} + const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] // Why: every hook here shares the session outbox store; one left mounted would drain the next test's. @@ -175,7 +182,7 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', @@ -183,7 +190,7 @@ describe('a send the host rejected because the agent never started', () => { fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('hello')).toBe(true)) @@ -214,7 +221,7 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', @@ -222,7 +229,7 @@ describe('a send the host rejected because the agent never started', () => { fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('hello')).toBe(true)) @@ -350,7 +357,7 @@ describe('a send the host rejected because the agent never started', () => { it('drops a message rejected before a restart once the journal says so, from storage too', async () => { writeOutbox('session-1', [rejectedBeforeRestart()]) const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', @@ -358,7 +365,7 @@ describe('a send the host rejected because the agent never started', () => { fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) expect(result.current.outbox).toHaveLength(1) @@ -395,7 +402,7 @@ describe('a send the host rejected because the agent never started', () => { } ] const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', @@ -403,7 +410,7 @@ describe('a send the host rejected because the agent never started', () => { fence: 1, submissions: props.submissions }), - { initialProps: { submissions: rejected } } + { initialProps: outboxProps(rejected) } ) await waitFor(() => expect(result.current.outbox[0]?.state).toBe('rejected')) @@ -444,7 +451,7 @@ describe('a send the host rejected because the agent never started', () => { }) ) const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', @@ -452,7 +459,7 @@ describe('a send the host rejected because the agent never started', () => { fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('first')).toBe(true)) @@ -479,7 +486,7 @@ describe('a send the host rejected because the agent never started', () => { ) const target = { kind: 'local' } as const const { result, rerender } = renderHook( - (props: { submissions: AgentJournalSubmission[]; rows?: AgentJournalRenderItem[] }) => + (props: OutboxProps) => useStructuredAgentSessionOutbox({ journalItems: props.rows ?? NO_JOURNAL_ITEMS, sessionId: 'session-1', @@ -487,7 +494,7 @@ describe('a send the host rejected because the agent never started', () => { fence: 1, submissions: props.submissions }), - { initialProps: { submissions: NO_SUBMISSIONS } } + { initialProps: outboxProps(NO_SUBMISSIONS) } ) act(() => expect(result.current.send('hello')).toBe(true)) From bc82e4210ba90b2a0c5cff9e7583941712207e5e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:09:46 -0700 Subject: [PATCH 17/19] fix(native-chat): write nothing when a journal batch settles nothing in the outbox The outbox re-reads the journal on every batch since it waits for a rejected message's row to load. Its reconcile now returns each unchanged entry, and the list, as themselves (a message left in doubt included), so a batch that changes nothing writes nothing to storage. The reconcile moves to its own module. A copy the host recorded and rejected owes no delivery, so it no longer keeps a hidden pane reading the journal. --- ...structured-agent-session-outbox-storage.ts | 12 +- ...-agent-session-outbox.batch-churn.test.tsx | 117 ++++++++++++++++++ .../use-structured-agent-session-outbox.ts | 10 +- ...structured-agent-session-draft-hand-off.ts | 13 +- ...red-agent-session-outbox-reconcile.test.ts | 100 +++++++++++++++ ...ructured-agent-session-outbox-reconcile.ts | 69 +++++++++++ ...ed-agent-session-outbox-retry-hold.test.ts | 2 +- src/shared/structured-agent-session-outbox.ts | 62 +--------- ...red-agent-session-send-disposition.test.ts | 2 +- 9 files changed, 309 insertions(+), 78 deletions(-) create mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx create mode 100644 src/shared/structured-agent-session-outbox-reconcile.test.ts create mode 100644 src/shared/structured-agent-session-outbox-reconcile.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddba..cbc74133d7a9 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -1,6 +1,7 @@ import { createStructuredAgentSessionOutboxEntry, parseStructuredAgentSessionOutboxEntry, + structuredAgentSessionEntryRejectedByHost, type StructuredAgentSessionAttachment, type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' @@ -54,9 +55,14 @@ function publishUndelivered(sessionId: string, undelivered: boolean): void { } } +/** Whether any entry still owes a delivery: a copy the host recorded and rejected owes none. */ +function owesDelivery(entries: readonly StructuredAgentSessionOutboxEntry[]): boolean { + return entries.some((entry) => !structuredAgentSessionEntryRejectedByHost(entry)) +} + /** Keep the journal subscription alive while this session still owes delivery. */ export function hasUndeliveredStructuredAgentSessionOutbox(sessionId: string): boolean { - return undeliveredSessions.get(sessionId)?.undelivered ?? readOutbox(sessionId).length > 0 + return undeliveredSessions.get(sessionId)?.undelivered ?? owesDelivery(readOutbox(sessionId)) } export function subscribeToUndeliveredStructuredAgentSessionOutbox( @@ -65,7 +71,7 @@ export function subscribeToUndeliveredStructuredAgentSessionOutbox( ): () => void { let subscription = undeliveredSessions.get(sessionId) if (!subscription) { - subscription = { undelivered: readOutbox(sessionId).length > 0, listeners: new Set() } + subscription = { undelivered: owesDelivery(readOutbox(sessionId)), listeners: new Set() } undeliveredSessions.set(sessionId, subscription) } const owned = subscription @@ -92,7 +98,7 @@ export function writeOutbox( } else { localStorage.setItem(storageKey(sessionId), JSON.stringify(entries)) } - publishUndelivered(sessionId, entries.length > 0) + publishUndelivered(sessionId, owesDelivery(entries)) return true } catch { return false diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx new file mode 100644 index 000000000000..b6dfa17790a1 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +// The outbox re-reads the journal on every batch; a batch that settles nothing writes nothing, so a +// message left in doubt costs no storage write per streamed delta. And a copy the host recorded and +// rejected owes no delivery, so it keeps no hidden pane reading the journal. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(() => new Promise(() => {})) +})) + +import { + hasUndeliveredStructuredAgentSessionOutbox, + writeOutbox +} from './structured-agent-session-outbox-storage' +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +afterEach(cleanup) + +beforeEach(() => { + localStorage.clear() +}) + +const SESSION = 'session-churn' + +function stored( + id: string, + patch: Partial> +) { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: SESSION, + text: id, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function inDoubt(clientMessageId: string): AgentJournalSubmission { + return { + clientMessageId, + fence: 1, + payloadFingerprint: clientMessageId, + dispatchState: 'unknown', + providerItemId: null, + reason: 'in doubt', + submittedAt: 5, + resolvedAt: 6 + } +} + +function streamed(sequence: number): AgentJournalRenderItem[] { + return [ + { + itemId: `answer-${sequence}`, + revision: sequence, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'streaming' }] } + } + ] +} + +it('writes nothing to storage across 50 batches while a message waits in doubt', async () => { + writeOutbox(SESSION, [stored('doubt', { state: 'dispatching', lastAttemptAt: 2 })]) + const { rerender } = renderHook( + (props: { items: AgentJournalRenderItem[]; submissions: AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: SESSION, + target: { kind: 'local' }, + fence: 1, + submissions: props.submissions, + journalItems: props.items + }), + { initialProps: { items: streamed(1), submissions: [inDoubt('doubt')] } } + ) + await act(async () => {}) + const writes = vi.spyOn(Storage.prototype, 'setItem') + + for (let sequence = 2; sequence <= 51; sequence += 1) { + // Each batch is a new journal array and a new submissions array with the same content. + rerender({ items: streamed(sequence), submissions: [inDoubt('doubt')] }) + } + await act(async () => {}) + + expect(writes).not.toHaveBeenCalled() + writes.mockRestore() +}) + +it('counts a copy the host recorded and rejected as owing no delivery', () => { + writeOutbox(SESSION, [ + stored('recorded', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' } + }) + ]) + expect(hasUndeliveredStructuredAgentSessionOutbox(SESSION)).toBe(false) + + writeOutbox(SESSION, [ + stored('recorded', { + state: 'rejected', + lastFailure: { kind: 'rejected', reason: 'Orca restarted before this message was sent.' } + }), + stored('waiting', {}) + ]) + expect(hasUndeliveredStructuredAgentSessionOutbox(SESSION)).toBe(true) +}) 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 f538e37fef0c..5a3ebcc9ce4f 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 @@ -153,13 +153,11 @@ export function useStructuredAgentSessionOutbox(args: { ]) const next = reconcileStructuredAgentSessionOutboxWithQueue(current, submissions, journalItems) const admittedInFlight = journalAnswersInFlightSend(submissions, inFlightIdRef.current) - if ( - admittedInFlight || - next.some((entry, index) => entry !== current[index]) || - next.length !== current.length - ) { + // The reconcile returns `current` itself when no entry changed, so a batch that changes + // nothing writes nothing. + if (admittedInFlight || next !== current) { restoreWithdrawn.byHost(current, submissions) - commitStructuredAgentSessionOutbox(sessionId, next) + commitStructuredAgentSessionOutbox(sessionId, [...next]) } // Keyed on the entry actually in flight, which is no longer always the head: the journal // owning it outranks a send promise that has not settled, so release single-flight and make diff --git a/src/shared/structured-agent-session-draft-hand-off.ts b/src/shared/structured-agent-session-draft-hand-off.ts index 09a4545779ac..4e756d331f75 100644 --- a/src/shared/structured-agent-session-draft-hand-off.ts +++ b/src/shared/structured-agent-session-draft-hand-off.ts @@ -3,10 +3,8 @@ // is how a client knows the host has taken a message over. import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' -import { - reconcileStructuredAgentSessionOutbox, - type StructuredAgentSessionOutboxEntry -} from './structured-agent-session-outbox' +import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' /** Ids of the queued drafts the journal shows handed off, in any dispatch state. An outbox entry * under one of these ids belongs to the host: its card or bubble carries the text from here. */ @@ -31,12 +29,11 @@ export function reconcileStructuredAgentSessionOutboxWithQueue( entries: readonly StructuredAgentSessionOutboxEntry[], submissions: readonly AgentJournalSubmission[], items: readonly AgentJournalRenderItem[] -): StructuredAgentSessionOutboxEntry[] { +): readonly StructuredAgentSessionOutboxEntry[] { const handedOff = handedOffQueuedMessageIds(submissions) + const ours = entries.filter((entry) => !handedOff.has(entry.clientMessageId)) return reconcileStructuredAgentSessionOutbox( - handedOff.size === 0 - ? entries - : entries.filter((entry) => !handedOff.has(entry.clientMessageId)), + ours.length === entries.length ? entries : ours, submissions, items ) diff --git a/src/shared/structured-agent-session-outbox-reconcile.test.ts b/src/shared/structured-agent-session-outbox-reconcile.test.ts new file mode 100644 index 000000000000..0336bd649e40 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-reconcile.test.ts @@ -0,0 +1,100 @@ +// The reconcile runs on every journal batch, so reading an unchanged journal again must change +// nothing: the same entries, and the same list. + +import { expect, it } from 'vitest' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { + AgentJournalDispatchState, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' + +function entry( + id: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: 'session-1', + text: id, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function submission(id: string, dispatchState: AgentJournalDispatchState): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: id, + dispatchState, + providerItemId: null, + reason: dispatchState === 'unknown' ? 'in doubt' : null, + ...(dispatchState === 'rejected' ? { rejection: { kind: 'hostRestarted' } } : {}), + submittedAt: 5, + resolvedAt: dispatchState === 'pending' ? null : 6 + } +} + +function row(id: string): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] } + } +} + +it('returns every kept entry, and the list, as themselves when read again', () => { + const entries = [ + entry('queued'), + entry('landing', { state: 'unconfirmed', lastFailure: { kind: 'failed' } }), + entry('sending', { state: 'dispatching' }), + entry('doubt', { state: 'dispatching', lastFailure: { kind: 'failed' } }), + entry('rejected-unloaded', { state: 'dispatching' }), + entry('rejected-loaded', { state: 'dispatching' }), + entry('delivered', { state: 'dispatching' }), + entry('refused', { + state: 'rejected', + lastFailure: { kind: 'refused', code: 'agent_session_operation_invalid' } + }) + ] + const submissions = [ + submission('landing', 'pending'), + submission('sending', 'pending'), + submission('doubt', 'unknown'), + submission('rejected-unloaded', 'rejected'), + submission('rejected-loaded', 'rejected'), + submission('delivered', 'accepted') + ] + const items = [row('rejected-loaded')] + + const first = reconcileStructuredAgentSessionOutbox(entries, submissions, items) + expect(first.map((kept) => [kept.clientMessageId, kept.state])).toEqual([ + ['queued', 'queued'], + ['landing', 'dispatching'], + ['sending', 'dispatching'], + ['doubt', 'unconfirmed'], + ['rejected-unloaded', 'rejected'], + ['refused', 'rejected'] + ]) + const second = reconcileStructuredAgentSessionOutbox(first, submissions, items) + expect(second).toBe(first) + second.forEach((kept, index) => expect(kept).toBe(first[index])) +}) + +it('returns the list it was given when nothing changes', () => { + const entries = [entry('queued'), entry('doubt', { state: 'unconfirmed' })] + expect(reconcileStructuredAgentSessionOutbox(entries, [submission('doubt', 'unknown')], [])).toBe( + entries + ) +}) diff --git a/src/shared/structured-agent-session-outbox-reconcile.ts b/src/shared/structured-agent-session-outbox-reconcile.ts new file mode 100644 index 000000000000..9fa699eece1f --- /dev/null +++ b/src/shared/structured-agent-session-outbox-reconcile.ts @@ -0,0 +1,69 @@ +// How the journal's view of each submission settles the outbox: the sibling of +// `disposeStructuredAgentSessionSendResult`, which folds a single send's answer. + +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' +import { + structuredAgentSessionEntryRejectedByHost, + structuredAgentSessionRejectedFailure, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +export function reconcileStructuredAgentSessionOutbox( + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[], + /** The loaded journal rows: a rejected message leaves only once the row that draws it is here. */ + items: readonly AgentJournalRenderItem[] +): readonly StructuredAgentSessionOutboxEntry[] { + const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) + let loaded: Set | undefined + // An entry whose reconciled value is unchanged is returned as itself, and so is the list when + // none changed: a caller re-reading every journal batch writes nothing then. + const next = entries.flatMap((entry) => { + const submission = settled.get(entry.clientMessageId) + // Settled by the host: its history shows one delivered; a withdrawn one goes back to the + // composer. + if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { + return [] + } + if (submission?.dispatchState === 'rejected') { + // Its row draws it once loaded; until then the entry does, as the host recorded it. An older + // host leaves that row where it was sent, which may be outside the loaded window. + loaded ??= new Set(items.map((item) => item.itemId)) + if (loaded.has(agentJournalSubmissionKey(entry.clientMessageId))) { + return [] + } + const lastFailure = structuredAgentSessionRejectedFailure(submission) + return [ + structuredAgentSessionEntryRejectedByHost(entry) + ? entry + : { ...entry, state: 'rejected' as const, lastFailure } + ] + } + if (submission?.dispatchState === 'pending') { + if (entry.state === 'dispatching') { + return [entry] + } + // The host has it, so no failure of an earlier attempt describes it now. + const { lastFailure: _landed, ...landed } = entry + return [{ ...landed, state: 'dispatching' as const }] + } + if ( + submission?.dispatchState === 'unknown' && + entry.retryAfterUnknownSubmittedAt !== -1 && + entry.retryAfterUnknownSubmittedAt !== submission.submittedAt + ) { + // In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send. + if (entry.state === 'unconfirmed' && entry.lastFailure === undefined) { + return [entry] + } + const { lastFailure: _superseded, ...inDoubt } = entry + return [{ ...inDoubt, state: 'unconfirmed' as const }] + } + return [entry] + }) + return next.length === entries.length && next.every((entry, index) => entry === entries[index]) + ? entries + : 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 f894fe27de04..cd2bb570b57a 100644 --- a/src/shared/structured-agent-session-outbox-retry-hold.test.ts +++ b/src/shared/structured-agent-session-outbox-retry-hold.test.ts @@ -2,9 +2,9 @@ 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 { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { admitStructuredAgentSessionOutboxEntry, structuredAgentSessionEntryHeldForRetry diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 66aad2745b5a..71278043b475 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -1,11 +1,6 @@ import type { AgentSessionFailureFact } from './agent-session-failure' import { readWholeAgentSessionFailureFact } from './agent-session-failure' -import { agentJournalSubmissionKey } from './agent-session-journal-item-key' -import type { - AgentJournalMessageItem, - AgentJournalRenderItem, - AgentJournalSubmission -} from './agent-session-journal-types' +import type { AgentJournalMessageItem } from './agent-session-journal-types' import { parseAgentSessionWriteFailure, type AgentSessionWriteFailure, @@ -16,14 +11,13 @@ import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' -import { dispatchWasWithdrawn } from './structured-agent-session-dispatch-rejection' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `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 leaves once the - * client holds its rejected submission: the host places that row at the rejection, so the newest - * page carries it, and until then the user may Dismiss it. */ + * row that draws it is loaded (`structured-agent-session-outbox-reconcile`); until then the user + * may Dismiss it, and it owes no delivery. */ export type StructuredAgentSessionOutboxState = | 'queued' | 'dispatching' @@ -221,56 +215,6 @@ export function requeueStructuredAgentSessionSendRefusal( } } -export function reconcileStructuredAgentSessionOutbox( - entries: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[], - /** The loaded journal rows: a rejected message leaves only once the row that draws it is here. */ - items: readonly AgentJournalRenderItem[] -): StructuredAgentSessionOutboxEntry[] { - const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) - let loaded: Set | undefined - return entries.flatMap((entry) => { - const submission = settled.get(entry.clientMessageId) - // Settled by the host: its history shows one delivered; a withdrawn one goes back to the - // composer. - if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { - return [] - } - if (submission?.dispatchState === 'rejected') { - // Its row draws it once loaded; until then the entry does, as the host recorded it. An older - // host leaves that row where it was sent, which may be outside the loaded window. - loaded ??= new Set(items.map((item) => item.itemId)) - if (loaded.has(agentJournalSubmissionKey(entry.clientMessageId))) { - return [] - } - const lastFailure = structuredAgentSessionRejectedFailure(submission) - return [ - structuredAgentSessionEntryRejectedByHost(entry) - ? entry - : { ...entry, state: 'rejected' as const, lastFailure } - ] - } - if (submission?.dispatchState === 'pending') { - if (entry.state === 'dispatching') { - return [entry] - } - // The host has it, so no failure of an earlier attempt describes it now. - const { lastFailure: _landed, ...landed } = entry - return [{ ...landed, state: 'dispatching' as const }] - } - if ( - submission?.dispatchState === 'unknown' && - entry.retryAfterUnknownSubmittedAt !== -1 && - entry.retryAfterUnknownSubmittedAt !== submission.submittedAt - ) { - // In doubt now, not failed: the probe's resend decides it, as for any unconfirmed send. - const { lastFailure: _superseded, ...inDoubt } = entry - return [{ ...inDoubt, state: 'unconfirmed' as const }] - } - return [entry] - }) -} - export function parseStructuredAgentSessionOutboxEntry( value: unknown, sessionId: string diff --git a/src/shared/structured-agent-session-send-disposition.test.ts b/src/shared/structured-agent-session-send-disposition.test.ts index f3916e1b9035..9605764b0179 100644 --- a/src/shared/structured-agent-session-send-disposition.test.ts +++ b/src/shared/structured-agent-session-send-disposition.test.ts @@ -19,9 +19,9 @@ import { } from './structured-agent-session-send-disposition' import { createStructuredAgentSessionOutboxEntry, - reconcileStructuredAgentSessionOutbox, type StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' const entry: StructuredAgentSessionOutboxEntry = createStructuredAgentSessionOutboxEntry({ From 6ef010ae1374609b42c8bff8b15b392ce3d33bb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:10:41 -0700 Subject: [PATCH 18/19] test(native-chat): count storage writes on the outbox's own storage object --- .../use-structured-agent-session-outbox.batch-churn.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx index b6dfa17790a1..26e46bd2bbf8 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.batch-churn.test.tsx @@ -85,7 +85,7 @@ it('writes nothing to storage across 50 batches while a message waits in doubt', { initialProps: { items: streamed(1), submissions: [inDoubt('doubt')] } } ) await act(async () => {}) - const writes = vi.spyOn(Storage.prototype, 'setItem') + const writes = vi.spyOn(localStorage, 'setItem') for (let sequence = 2; sequence <= 51; sequence += 1) { // Each batch is a new journal array and a new submissions array with the same content. From 804ca8c69dcd8844e6126014dfe917d782466dfc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:52:42 -0700 Subject: [PATCH 19/19] fix(native-chat): let a recorded rejected message's outbox copy leave on its own, with no Dismiss The outbox copy of a message the host recorded and then rejected draws it only while the host's row is not loaded, and leaves on the batch or page that loads that row. It owes no delivery and offers no control: sending it again is a new message. The Dismiss that let the user clear it is gone, from the outbox, the notices and the session controller. --- ...iveChatMessageList.unsent-message.test.tsx | 1 - ...tiveChatStructuredSession.test-harness.tsx | 1 - .../NativeChatStructuredSession.tsx | 1 - ...tiveChatStructuredSessionDelivery.test.tsx | 1 - ...red-agent-session-delivery-notices.test.ts | 28 +-- ...ructured-agent-session-delivery-notices.ts | 36 +-- ...sion-message-projection.older-host.test.ts | 6 +- ...n-message-projection.recorded-copy.test.ts | 218 ++++++++++++++++++ ...ssage-projection.rejected-in-place.test.ts | 46 +--- ...ed-agent-session-delivery-notices.test.tsx | 2 - ...ructured-agent-session-delivery-notices.ts | 14 +- ...ed-agent-session-outbox-admission.test.tsx | 1 - ...nt-session-outbox-rejection-cause.test.tsx | 21 +- ...gent-session-outbox-relaunch-hold.test.tsx | 1 - .../use-structured-agent-session-outbox.ts | 11 - .../use-structured-agent-session.ts | 1 - src/shared/structured-agent-session-outbox.ts | 5 +- 17 files changed, 261 insertions(+), 133 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx index 2429554c7989..ac6ab467ace2 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.unsent-message.test.tsx @@ -267,7 +267,6 @@ describe('a message the host rejected after a crash, with no outbox entry left', [], 'Claude', vi.fn(), - () => {}, submissions, [], new Set() diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index d522e1860043..ee876868c351 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -181,7 +181,6 @@ export function createStructuredSessionMocks() { submissions: mocks.submissions, send: outbox.send, retry: outbox.retry, - dismiss: outbox.dismiss, isWorking: mocks.isWorking, backgroundTasks: { show: mocks.showBackgroundTasks || mocks.monitoringBackgroundTasks, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 92582ef3b2f5..96cc16b15ba3 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -117,7 +117,6 @@ export function NativeChatStructuredSession( failedHere: controller.failedHere, queuedMessageIds: controller.queuedMessageIds, retry: controller.retry, - dismiss: controller.dismiss, agentName: agentLabel }) const viewState = selectNativeChatViewState(session, { readRetries: true }) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index abbda32b7ba9..bd117963c59d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -87,7 +87,6 @@ vi.mock('./use-structured-agent-session', async () => { submissions: mocks.submissions, send: outbox.send, retry: outbox.retry, - dismiss: outbox.dismiss, isWorking: false, isMonitoringBackgroundTasks: mocks.monitoringBackgroundTasks, supportsBackgroundTaskStop: mocks.supportsBackgroundTaskStop, 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 0012be3a1aca..9e3cfedbdd15 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 @@ -48,7 +48,6 @@ function texts( outbox, 'Claude', () => {}, - () => {}, submissions, startFailures, new Set(outbox.map((candidate) => candidate.clientMessageId)) @@ -77,7 +76,6 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', retry, - () => {}, [], [], new Set(['first', 'second']) @@ -97,10 +95,9 @@ describe('the notice on each message that did not go through', () => { expect(notices.get(agentJournalSubmissionKey('second'))?.onRetry).toBeUndefined() }) - // However this chat learned of it: the host recorded it, so it has a Dismiss and never a Retry. - it('gives a message the host rejected before this chat opened a Dismiss, not a Retry', () => { + // However this chat learned of it: the host recorded it, so it has no control at all. + it('gives a message the host rejected before this chat opened no Retry and no Dismiss', () => { const retry = vi.fn() - const dismiss = vi.fn() const notice = structuredAgentSessionDeliveryNotices( [ entry('earlier', { @@ -110,14 +107,11 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', retry, - dismiss, [], [], NOT_FAILED_HERE ).get(agentJournalSubmissionKey('earlier')) - expect(notice?.onRetry).toBeUndefined() - notice?.onDismiss?.() - expect(dismiss).toHaveBeenCalledExactlyOnceWith('earlier') + expect(notice).toEqual({ text: 'Claude messages support at most 20 images' }) expect(retry).not.toHaveBeenCalled() }) @@ -132,7 +126,6 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', () => {}, - () => {}, [], [], NOT_FAILED_HERE @@ -229,7 +222,6 @@ describe('the notice on each message that did not go through', () => { outbox, 'Claude', retry, - () => {}, [], [], NOT_FAILED_HERE @@ -261,7 +253,6 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', retry, - () => {}, [], [], NOT_FAILED_HERE @@ -449,7 +440,6 @@ describe('the notice on each message that did not go through', () => { ], 'Claude', vi.fn(), - () => {}, [], [], NOT_FAILED_HERE @@ -473,15 +463,9 @@ describe('the notice on each message that did not go through', () => { } }) const words = (failedHere: ReadonlySet) => - structuredAgentSessionDeliveryNotices( - [held], - 'Claude', - vi.fn(), - () => {}, - [], - [], - failedHere - ).get(agentJournalSubmissionKey('held')) + structuredAgentSessionDeliveryNotices([held], 'Claude', vi.fn(), [], [], failedHere).get( + agentJournalSubmissionKey('held') + ) expect(words(NOT_FAILED_HERE)).toMatchObject({ text: 'Your message was not sent.' }) expect(words(NOT_FAILED_HERE)?.onRetry).toBeDefined() expect(words(new Set(['held']))?.text).toBe( 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 63df80175646..1ef4067357df 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 @@ -10,8 +10,8 @@ // A message the host recorded and then rejected is drawn from the host's history, worded from the // journal's own fact, with no Retry: sending it again is a new message. A rejection that is a // failed start's, the fact its loaded row states, says only that it was not sent: the row already -// says why. Until the journal's submissions say so, the outbox draws it from its smaller copy, with -// a Dismiss in case they never do. +// says why. Until its row loads, the outbox draws it from its smaller copy, which leaves on the batch +// or page that loads the row. import { readAgentSessionFailureFact, @@ -149,8 +149,6 @@ export function structuredAgentSessionDeliveryNotices( outbox: readonly StructuredAgentSessionOutboxEntry[], agentName: string, retry: (clientMessageId: string) => void, - /** Clears a message the host recorded and rejected from the outbox, which draws it until then. */ - dismiss: (clientMessageId: string) => void, /** The journal's rows, whose rejected ones carry more of a rejection than the message keeps. */ submissions: readonly AgentJournalSubmission[], /** What the loaded start-failure rows state, from `structuredAgentSessionStartFailureFacts`. */ @@ -178,41 +176,27 @@ export function structuredAgentSessionDeliveryNotices( entry.clientMessageId === held ) { // Its own Retry is the step, so the words leave out sending again. One the host recorded is - // the host's: sending it again is a new message, so it has no Retry, only a Dismiss. - const recorded = structuredAgentSessionEntryRejectedByHost(entry) - const retryControl = (stalledFrom === -1 || index <= stalledFrom) && !recorded + // the host's: sending it again is a new message, so it has no Retry. + const retryControl = + (stalledFrom === -1 || index <= stalledFrom) && + !structuredAgentSessionEntryRejectedByHost(entry) const text = deliveryNoticeText(entry, { agentName, retryControl }, failedHere) notices.set( agentJournalSubmissionKey(entry.clientMessageId), - recorded - ? { text, onDismiss: () => dismiss(entry.clientMessageId) } - : retryControl - ? { text, onRetry: () => retry(entry.clientMessageId) } - : { text } + retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } ) } } - // After the outbox's, in the host's words. The outbox copy draws it while the row is not loaded - // (an older host may leave that row outside the window), so its Dismiss stays until it leaves. + // After the outbox's: in the host's words, whether its row or the outbox's copy draws it. const shown = structuredAgentSessionRejectedShownInPlace( submissions, queuedMessageIds, commandItemIds ) - const outboxDraws = new Set( - outbox.filter(structuredAgentSessionEntryRejectedByHost).map((entry) => entry.clientMessageId) - ) for (const submission of rejected.values()) { - const { clientMessageId } = submission - const id = agentJournalSubmissionKey(clientMessageId) + const id = agentJournalSubmissionKey(submission.clientMessageId) if (shown.has(id)) { - const text = hostRejectionNoticeText(submission, agentName, startFailures) - notices.set( - id, - outboxDraws.has(clientMessageId) - ? { text, onDismiss: () => dismiss(clientMessageId) } - : { text } - ) + notices.set(id, { text: hostRejectionNoticeText(submission, agentName, startFailures) }) } } return notices diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts index e29fec619b6c..7cc13a0a4eb6 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts @@ -1,6 +1,6 @@ // An older host leaves a rejected message where it was sent. When that is older than the loaded // window, the chat holds its rejected submission but not its row: the outbox copy keeps drawing it, -// with a Dismiss, until the page holding the row loads, and then the host's row draws it instead. +// with no control, until the page holding the row loads, and then the host's row draws it instead. import { expect, it } from 'vitest' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' @@ -134,13 +134,11 @@ it("keeps the outbox copy of a rejected message whose row is outside the window, kept, 'Claude', () => {}, - () => {}, state.submissions, [], new Set() ).get(MESSAGE_ID) - expect(notice?.onDismiss).toBeDefined() - expect(notice?.onRetry).toBeUndefined() + expect(notice).toEqual({ text: 'Orca restarted before this message was sent.' }) // Paging back loads the row: the outbox lets go, and the host's row is the one drawn. state = reduceStructuredAgentSession(state, { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts new file mode 100644 index 000000000000..84e10ca18b7b --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.recorded-copy.test.ts @@ -0,0 +1,218 @@ +// An outbox copy of a message the host recorded and then rejected draws it only while the host's row +// is not loaded, and leaves, with no user action, on the batch or page that loads that row. It never +// offers a control: sending it again is a new message. + +import { expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionHistoryPage } from '../../../../shared/agent-session-wire' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { reconcileStructuredAgentSessionOutboxWithQueue } from '../../../../shared/structured-agent-session-draft-hand-off' +import { + createStructuredAgentSessionOutboxEntry, + structuredAgentSessionRejectedFailure, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { admitStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox-admission' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const NO_CARDS: readonly string[] = [] +const MESSAGE_ID = agentJournalSubmissionKey('m') +const WORDS = 'Orca restarted before this message was sent.' + +function answer(sequence: number): AgentJournalRenderItem { + return { + itemId: `a-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'ok' }] } + } +} + +function hostRow(sequence: number): AgentJournalRenderItem { + return { + itemId: MESSAGE_ID, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'sent text' }] } + } +} + +const REJECTED: AgentJournalSubmission = { + clientMessageId: 'm', + fence: 1, + payloadFingerprint: 'm', + dispatchState: 'rejected', + providerItemId: null, + reason: DISPATCH_REJECTED_HOST_RESTARTED, + rejection: { kind: 'hostRestarted' }, + submittedAt: 10, + resolvedAt: 1100 +} + +function copy(patch: Partial = {}) { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'm', + sessionId: 's', + text: 'sent text', + attachments: [], + queuedAt: 10 + }), + ...patch + } +} + +/** A copy the host's own reply already marked rejected, as the outbox stores it. */ +const RECORDED_COPY = copy({ + state: 'rejected', + lastFailure: structuredAgentSessionRejectedFailure(REJECTED) +}) + +function page( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] +): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + return { + sessionId: 's', + epoch: 'e', + direction: 'tail', + items, + removedItemIds: [], + submissions, + window: { + oldest: { epoch: 'e', sequence: oldest }, + newest: { epoch: 'e', sequence: items.at(-1)?.sequence ?? 0 }, + nextCursor: { epoch: 'e', sequence: oldest } + }, + liveCursor: { epoch: 'e', sequence: items.at(-1)?.sequence ?? 0 }, + hasOlder: true, + hasNewer: false + } +} + +function opened(items: AgentJournalRenderItem[], submissions: AgentJournalSubmission[]) { + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 's', fence: 1, page: page(items, submissions) } + }) +} + +const WINDOW = Array.from({ length: 100 }, (_, index) => answer(1000 + index)) + +/** What the chat draws for this message, and the notice its row carries. */ +function shown( + state: StructuredAgentSessionState, + outbox: readonly StructuredAgentSessionOutboxEntry[] +) { + const kept = reconcileStructuredAgentSessionOutboxWithQueue( + outbox, + state.submissions, + state.items + ) + const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions, NO_CARDS) + .filter((message) => message.role === 'user') + .map(({ id, unsent }) => ({ id, unsent })) + const notice = structuredAgentSessionDeliveryNotices( + kept, + 'Claude', + () => {}, + state.submissions, + [], + new Set() + ).get(MESSAGE_ID) + return { kept, rows, notice } +} + +it('leaves on the live batch that rejects it on a host that places the row at the rejection', () => { + let state = opened(WINDOW, []) + const sent = copy({ state: 'dispatching', lastAttemptAt: 9 }) + state = reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 's', + batch: { + cursor: { epoch: 'e', sequence: 1100 }, + items: [hostRow(1100)], + removedItemIds: [], + submissions: [REJECTED] + } + } + }) + + expect(shown(state, [sent])).toEqual({ + kept: [], + rows: [{ id: MESSAGE_ID, unsent: true }], + notice: { text: WORDS } + }) +}) + +it('leaves on the page that opens the chat when that page holds the row', () => { + const state = opened([...WINDOW, hostRow(1100)], [REJECTED]) + + expect(shown(state, [RECORDED_COPY])).toEqual({ + kept: [], + rows: [{ id: MESSAGE_ID, unsent: true }], + notice: { text: WORDS } + }) +}) + +it('draws once, with no control, while its row is outside the window, and leaves when that page loads', () => { + // An older host leaves the row where it was sent; the live batch brings only its record. + let state = reduceStructuredAgentSession(opened(WINDOW, []), { + type: 'event', + event: { + type: 'batch', + sessionId: 's', + batch: { + cursor: { epoch: 'e', sequence: 1100 }, + items: [hostRow(10)], + removedItemIds: [], + submissions: [REJECTED] + } + } + }) + const before = shown(state, [copy({ state: 'dispatching', lastAttemptAt: 9 })]) + expect(before.kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) + expect(before.rows).toEqual([{ id: MESSAGE_ID, unsent: true }]) + expect(before.notice).toEqual({ text: WORDS }) + + // Scrolling back loads the page; no control on the copy is involved. + state = reduceStructuredAgentSession(state, { + type: 'older-page', + requestedCursor: { epoch: 'e', sequence: 1000 }, + page: page( + [hostRow(10), ...Array.from({ length: 5 }, (_, index) => answer(995 + index))], + [REJECTED] + ) + }) + expect(shown(state, before.kept)).toEqual({ + kept: [], + rows: [{ id: MESSAGE_ID, unsent: true }], + notice: { text: WORDS } + }) +}) + +it('keeps a stored copy whose record is not held drawn, with no control, and never sends it', () => { + const state = opened(WINDOW, []) + const after = shown(state, [RECORDED_COPY]) + + expect(after.kept).toEqual([RECORDED_COPY]) + expect(after.rows).toEqual([{ id: MESSAGE_ID, unsent: true }]) + expect(after.notice).toEqual({ text: WORDS }) + // The drain passes over it: nothing it holds is ever on its way. + expect(admitStructuredAgentSessionOutboxEntry(after.kept)).toEqual({ state: 'idle', entry: null }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts index 75946b80a362..4e7577052256 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.rejected-in-place.test.ts @@ -171,7 +171,6 @@ describe('a message the host accepted and then rejected, on the desktop', () => [], 'Claude', () => {}, - () => {}, [SEED, restartRejected('lost', 'fix the parser', 3)], [], new Set() @@ -194,7 +193,6 @@ describe('a message the host accepted and then rejected, on the desktop', () => [], 'Claude', () => {}, - () => {}, [failedStart], [{ kind: 'notSignedIn' }], new Set() @@ -292,7 +290,6 @@ describe('a message the host accepted and then rejected, on the desktop', () => [], 'Claude', () => {}, - () => {}, submissions, [], new Set(), @@ -310,15 +307,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } ]) expect( - structuredAgentSessionDeliveryNotices( - [], - 'Claude', - () => {}, - () => {}, - submissions, - [], - new Set() - ).size + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) }) }) @@ -346,7 +335,6 @@ describe("one row per rejected message, the host's once it records the rejection [held], 'Claude', () => {}, - () => {}, [SEED], [], new Set(['held']) @@ -374,15 +362,14 @@ describe("one row per rejected message, the host's once it records the rejection [entry], 'Claude', () => {}, - () => {}, [SEED, rejected], [], new Set() ) - // In the host's words, never a Retry; a recorded copy keeps its Dismiss until it leaves. - expect([...notices.keys()]).toEqual([hostRow.id]) - expect(notices.get(hostRow.id)?.text).toBe('Orca restarted before this message was sent.') - expect(notices.get(hostRow.id)?.onRetry).toBeUndefined() + // In the host's words, with no control. + expect([...notices]).toEqual([ + [hostRow.id, { text: 'Orca restarted before this message was sent.' }] + ]) } }) @@ -434,15 +421,7 @@ describe('a rejected message the queue holds', () => { seedRow ]) expect( - structuredAgentSessionDeliveryNotices( - [], - 'Claude', - () => {}, - () => {}, - submissions, - [], - new Set() - ).size + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) }) @@ -455,16 +434,9 @@ describe('a rejected message the queue holds', () => { seedRow ]) expect( - structuredAgentSessionDeliveryNotices( - [], - 'Claude', - () => {}, - () => {}, - submissions, - [], - new Set(), - ['kept'] - ).size + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [ + 'kept' + ]).size ).toBe(0) expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([ seedRow, 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 44dcd439547a..28ef88564b90 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 @@ -67,7 +67,6 @@ it('keeps the same notices across batches in a chat whose only rejection a Stop failedHere: NONE, queuedMessageIds: NO_CARDS, retry: () => {}, - dismiss: () => {}, agentName: 'Claude' }), { initialProps: { submissions: [withdrawn('stopped')] } } @@ -90,7 +89,6 @@ function renderNotices(submissions: readonly AgentJournalSubmission[]) { failedHere: NONE, queuedMessageIds: NO_CARDS, retry: () => {}, - dismiss: () => {}, agentName: 'Claude' }), { initialProps: { submissions } } 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 9bb89e80bd27..26cdd00fb81f 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 @@ -20,20 +20,16 @@ export function useStructuredAgentSessionDeliveryNotices(args: { failedHere: ReadonlySet queuedMessageIds: readonly string[] retry: (clientMessageId: string) => void - dismiss: (clientMessageId: string) => void agentName: string }): ReadonlyMap { const { agentName, failedHere, outbox, queuedMessageIds, submissions } = args - // Read at click time, so the notices stay put while the outbox's actions are rebuilt each render. - const actionsRef = useRef({ retry: args.retry, dismiss: args.dismiss }) + // Read at click time, so the notices stay put while the outbox's Retry is rebuilt each render. + const retryRef = useRef(args.retry) useEffect(() => { - actionsRef.current = { retry: args.retry, dismiss: args.dismiss } + retryRef.current = args.retry }) const retry = useCallback((clientMessageId: string) => { - actionsRef.current.retry(clientMessageId) - }, []) - const dismiss = useCallback((clientMessageId: string) => { - actionsRef.current.dismiss(clientMessageId) + retryRef.current(clientMessageId) }, []) // Only a message shown as not sent reads the journal's rows (a withdrawn one draws nothing), so // in a chat without one a new batch of them re-renders no row. @@ -51,7 +47,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { outbox, agentName, retry, - dismiss, rejectionRows, startFailures, failedHere, @@ -62,7 +57,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { outbox, agentName, retry, - dismiss, rejectionRows, startFailures, failedHere, 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 b6b7e2318a8e..31015d09db91 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 @@ -289,7 +289,6 @@ describe('structured agent session outbox admission', () => { result.current.outbox, 'Claude', () => {}, - () => {}, [], [], result.current.failedHere diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 90beed6e96f3..e32006c50d01 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -294,9 +294,9 @@ describe('a send the host rejected because the agent never started', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) - // One stored host fact, one rendering: a message the host recorded and rejected has no Retry on - // any mount, however this mount learned of it, and a Dismiss that clears it from storage. - it('gives a message the host rejected no Retry but a Dismiss, on every mount', async () => { + // One stored host fact, one rendering: a message the host recorded and rejected, whose record this + // chat does not hold, keeps showing why on every mount, with no control, and is never sent again. + it('keeps a message the host rejected, with no control, on every mount, and never resends it', async () => { writeOutbox('session-1', [ { ...rejectedBeforeRestart(), @@ -317,7 +317,6 @@ describe('a send the host rejected because the agent never started', () => { outbox, 'Claude', () => {}, - () => {}, NO_SUBMISSIONS, [], failedHere @@ -343,13 +342,13 @@ describe('a send the host rejected because the agent never started', () => { const onSecond = notice(second.result.current.outbox, second.result.current.failedHere) for (const shown of [onFirst, onSecond]) { - expect(shown?.text).toBe(REASON) - expect(shown?.onRetry).toBeUndefined() - expect(shown?.onDismiss).toBeDefined() + expect(shown).toEqual({ text: REASON }) } - act(() => second.result.current.dismiss('rejected-before-restart')) - expect(second.result.current.outbox).toEqual([]) - expect(readOutbox('session-1')).toEqual([]) + await act(() => new Promise((resolve) => setTimeout(resolve, 1500))) + // Only the first mount's question about the send it left in doubt; nothing resends it. + expect(mocks.call).toHaveBeenCalledOnce() + expect(second.result.current.outbox.map((entry) => entry.state)).toEqual(['rejected']) + expect(readOutbox('session-1')).toHaveLength(1) expect(hasUndeliveredStructuredAgentSessionOutbox('session-1')).toBe(false) }, 10000) @@ -388,7 +387,7 @@ describe('a send the host rejected because the agent never started', () => { }) // An older host leaves a rejected message where it was sent, which may be outside the loaded - // window: the entry draws it, with a Dismiss, until the row that draws it loads. + // window: the entry draws it, with no control, until the row that draws it loads. it('keeps a rejected message whose row is not loaded, then lets it go once the row loads', async () => { writeOutbox('session-1', [ { ...rejectedBeforeRestart(), state: 'dispatching', lastFailure: undefined, lastAttemptAt: 5 } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx index aeac5625a4b5..2d3cfae4e0bf 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-relaunch-hold.test.tsx @@ -117,7 +117,6 @@ function notices(outbox: Outbox) { outbox.outbox, 'Claude', outbox.retry, - () => {}, [], [], outbox.failedHere 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 5a3ebcc9ce4f..210e9a79f4d9 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 @@ -299,23 +299,12 @@ export function useStructuredAgentSessionOutbox(args: { createOperationId: structuredSessionOperationId }) } - // A message the host recorded and rejected, cleared by the user from what the outbox draws. - const dismiss = (clientMessageId: string): void => { - forget(clientMessageId) - commitStructuredAgentSessionOutbox( - sessionId, - getStructuredAgentSessionOutbox(sessionId).filter( - (entry) => entry.clientMessageId !== clientMessageId - ) - ) - } return { outbox, error, failedHere, send, retry, - dismiss, withdrawUnsent } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 7382ae360add..46571fcf54f3 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 @@ -226,7 +226,6 @@ export function useStructuredAgentSession(args: { (!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) && outboxController.send(...input), retry: outboxController.retry, - dismiss: outboxController.dismiss, isWorking: transportState.isWorking, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 71278043b475..09ee877e346f 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -15,9 +15,8 @@ import { structuredAgentSessionPayloadFingerprint } from './structured-agent-ses import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `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 leaves once the - * row that draws it is loaded (`structured-agent-session-outbox-reconcile`); until then the user - * may Dismiss it, and it owes no delivery. */ + * it. One the host refused unrecorded waits for the user's Retry. One it recorded owes no delivery + * and leaves on the batch or page that loads its row (`structured-agent-session-outbox-reconcile`). */ export type StructuredAgentSessionOutboxState = | 'queued' | 'dispatching'