From 5cf3585b78da2dc629e77b3787f7744cb4cfdf0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:35:24 -0700 Subject: [PATCH] fix(native-chat): keep a message accepted before a quit or crash as a held card (#24660) * fix(native-chat): keep a message accepted before a quit or crash as a held card A send the host accepted while the agent was still starting, and never handed over, was rejected unseen at quit or at the next open after a crash. The next open now keeps a person's message (typed, or a launch's first prompt) as a waiting card at the head of the queue, held until Resume, Send now, Edit or Delete; quit no longer rejects it. Each submission records its source so a restart knows which leftovers to keep. A direct send's replay answers from its own record, never the queued arm. Cards shown without the queue capability hide Turn off queueing and the steer chord. * fix(native-chat): keep an unsent message as a held card at every close, not only a restart The host now keeps a person's message it accepted and never handed over with one rule wherever it can no longer hand it over: a quit or crash (settled at the next open) and a close of the chat (tab close, worktree teardown, orchestration stop). - The hold is card state: a new per-card hold_reason 'kept', published as the existing pausedReason, instead of a fake host_instance value. host_instance means the owner again, and the pause clause, adoption filter and /clear carry special cases are gone. A kept card holds the cards behind it until the person sends, edits or deletes it; a send_failed card still does not. - A card hand-off rejected by a restart or a close returns as a kept card (rejectedDraftSettlement), so a person's next message can never release it. - One hold function, parameterized by cause (hostRestarted / chatClosed), replaces the close path's plain rejection. - The phone shows published cards and per-card holds whatever the queue capability says; only queueing a new send stays gated. - source gains 'dispatch' for the orchestration preamble (still rejected); an unknown source is kept as written and never takes the legacy rule. - Kept cards from earlier settlements stay ahead of a batch's new ones. * test(native-chat): the dispatch preamble records its source * fix(native-chat): skip a kept card like a failed one, and make a quit leave the queue as a crash does - A kept card is held on its own, as a send_failed one is: the queue sends the cards behind it, and the "a message ahead needs attention" caption no longer appears behind it (desktop and phone). - Quit disposes the queue's drain together with delivery, so it mints no hand-off that only the next process could settle. - Only a Send the person asked for (origin client) that a restart or close cut short returns kept; the queue's own hand-off returns where it stood, under the restart's pause, as on main. - Comments that said only a capable host gets cards or the card actions now say the capability gates only queueing a new send. * refactor(native-chat): one dispose gate in the queue drain's step * test(native-chat): the downgrade test names the older build's /clear exception * fix(native-chat): re-check the drain's quit gate right before it appends a hand-off A drain step already past its first check when quit begins no longer makes a hand-off. Adds regression tests for that, for Send now on an ordinary card cut short by a quit, and for the open-time repair deriving kept from the hand-off's origin; fixes the pause and settlement comments that called a kept card one the queue never passes. * fix(native-chat): a card held on its own starts no restart pause for the others After a second restart a kept (or send_failed) card is another process's, but it waits for its own Send, so it no longer pauses every other card under "Queue paused because Orca restarted". * fix(native-chat): record on a kept send which card holds it, so no trace survives an Edit or Delete The rejection row of a send the host kept as a card now names that card (`keptAsQueuedMessageId`), in the same transaction that writes the card, and the fold publishes it on the submission. The shared projection draws such a send only as its card: once the card is sent, edited or deleted, neither the send nor the sending desktop's local copy of it shows. * chore(native-chat): leave the unused submission schema as main has it No client parses published submissions with it (they arrive as typed frames, and the host's history pages carry the field, as the Edit/Delete test reads); listing the field put the file over its line budget. * fix(native-chat): retire a kept send's local copy instead of only hiding it The outbox reconcile and the send disposition drop an entry whose submission the host rejected as kept as a card, as they already do for a Stop's withdrawal, so the copy never comes back as "Not sent / Retry" once the submission falls out of the loaded page. The projection reads the reconciled outbox, so its separate filter goes. * test(native-chat): move the queued-message rig's scripted provider into its own fixture The rig fixture grew past the 300-line limit once main's changes merged in. * fix(native-chat): hide a kept send by its own record, not by its card still existing The transcript hid a rejected send while a queued card held it under its id, so the card's Edit or Delete brought back a "Not sent" row. It now reads the send's own keptAsQueuedMessageId, which the host records with the rejection, and the live card list is no longer threaded to the transcript or the delivery notices. * refactor(native-chat): move a sent message's row writes into their own journal collaborator The journal store went past its line limit once main's ledger receipt joined this branch's transaction hook. The submission and dispatch-transition writes, and what commits in their transaction, now live in JournalSubmissionWriter; the store's methods delegate to it unchanged. * fix(native-chat): list a kept send's card id in the submission schema The schema drops keys it does not list, so a reader that kept a parsed submission would lose keptAsQueuedMessageId and source, both persisted with the row. The submission schema and the failure fact it shares with item bodies move to their own modules, with room for both fields. * test(native-chat): match the transcript and outbox hook signatures main and the swap changed * test(native-chat): import the journal types once in the queue-delivery test * fix(mobile): a resend the host kept as a card shows no error and returns no text The host answers a resend of a message it kept as a card with that message's rejected submission, marked keptAsQueuedMessageId. The phone read it as any rejection: "Message not sent" and the text back in the composer, while the card showed the same text. It now answers like a queued send, as the desktop's send disposition does: the id is spent, no error, and the card holds the text. * test(native-chat): pin that quit's first step stops the queue's hand-off Quit now stops delivery, the queue drain included, at its first step (stopDelivery), before teardown drains recovery. The drain-step quit test runs from that step as well as from the flush. * test(native-chat): give cards their source and store unknown sources as another build would #25078 made a card's source required, so the tests that insert a card pass the person's. The hold's unknown-kind and unreadable-source cases now rewrite the stored row the way a newer build would leave it, instead of casting a type. * refactor(native-chat): stop delivery and the queue drain in one line at quit Main's #25159 left the host at its line limit; quit's stop now disposes both in one expression instead of a block. * test(native-chat): hold the drain step without reading the call stack Bun formats a method's stack frame without its class ("at step"), so the quit test's caller check never matched, the step was never held, and both cases timed out once CI ran Vitest on Bun (#25840). Only the drain step heals owed queue bookkeeping, so the hold needs no caller check. --- .../MobileNativeChatQueuedMessages.tsx | 4 +- .../mobile-native-chat-controller-contract.ts | 4 +- .../mobile-native-chat-pending-echo.ts | 2 +- ...le-structured-queued-message-cards.test.ts | 25 +- .../mobile-structured-queued-message-cards.ts | 7 +- .../mobile-structured-send-delivery.test.ts | 28 + .../mobile-structured-send-delivery.ts | 6 + ...e-structured-agent-session-queued.test.tsx | 26 +- .../use-mobile-structured-agent-session.ts | 5 +- ...bile-structured-queued-message-controls.ts | 28 +- .../journal-dispatch-reducer.ts | 7 + .../journal-dispatch-settlement.test.ts | 31 +- .../journal-dispatch-settlement.ts | 27 +- .../journal-queued-messages.ts | 31 +- .../journal-row-builders.ts | 9 +- .../journal-row-schema.ts | 6 + .../journal-store-collaborators.ts | 8 + .../journal-store-contracts.ts | 4 + .../agent-session-journal/journal-store.ts | 47 +- .../journal-submission-fold.ts | 15 +- .../journal-submission-queued-link.test.ts | 2 +- .../journal-submission-writer.ts | 67 ++ .../journal-unsent-send-hold.test.ts | 486 +++++++++++++ .../journal-unsent-send-hold.ts | 208 ++++++ .../queued-message-holds.ts | 3 +- .../queued-message-pause.test.ts | 44 ++ .../queued-message-pause.ts | 20 +- .../queued-message-positions.ts | 31 + .../queued-message-retention.ts | 2 +- .../queued-message-settlement.ts | 8 +- .../queued-message-store.test.ts | 96 ++- .../queued-message-stored-row.ts | 98 +++ .../queued-message-table.ts | 138 +--- ...-agent-session-accept-then-deliver.test.ts | 22 +- ...red-agent-session-conversation-lifetime.ts | 8 +- ...uctured-agent-session-conversation-open.ts | 26 +- .../structured-agent-session-delivery-loop.ts | 33 +- .../structured-agent-session-host-delivery.ts | 6 +- .../structured-agent-session-host-lifetime.ts | 47 +- ...structured-agent-session-host-mutations.ts | 9 +- .../structured-agent-session-host-teardown.ts | 25 +- .../structured-agent-session-host.ts | 6 +- ...structured-agent-session-mutation-plans.ts | 18 +- ...gent-session-provider-child-record.test.ts | 75 +- ...ueued-message-rig-provider.test-fixture.ts | 120 ++++ ...session-queued-message-rig.test-fixture.ts | 115 +-- ...tructured-agent-session-queued-messages.ts | 16 +- ...ructured-agent-session-queued-mutations.ts | 21 +- ...ctured-agent-session-queued-publication.ts | 15 +- .../structured-agent-session-queued-send.ts | 4 +- ...-session-restart-continuation-wait.test.ts | 33 +- ...ed-agent-session-restart-ownership.test.ts | 3 + ...ent-session-restore-without-import.test.ts | 56 +- .../structured-agent-session-turns.ts | 3 + ...red-agent-session-unsent-send-hold.test.ts | 652 ++++++++++++++++++ ...tured-agent-session-working-at-teardown.ts | 2 +- ...structured-conversation-compaction.test.ts | 6 +- .../send-agent-turn-host.test.ts | 16 +- .../agent-launch-structured-prompt.test.ts | 2 + .../methods/agent-launch-structured-prompt.ts | 10 +- ...stration-structured-worker-session.test.ts | 9 + ...structured-agent-session-queued-methods.ts | 4 +- ...atMessageList.provider-retry-runs.test.tsx | 4 +- ...eChatMessageList.tool-stream-cost.test.tsx | 2 +- ...iveChatMessageList.unsent-message.test.tsx | 6 +- .../NativeChatQueuedMessageCard.tsx | 28 +- .../NativeChatQueuedMessageList.test.tsx | 49 +- .../NativeChatQueuedMessageList.tsx | 8 +- .../NativeChatStructuredSession.tsx | 1 - .../native-chat-rail-outline-parity.test.ts | 4 +- ...session-delivery-notices.kept-card.test.ts | 80 +++ ...red-agent-session-delivery-notices.test.ts | 1 - ...ructured-agent-session-delivery-notices.ts | 8 +- ...sion-message-projection.older-host.test.ts | 3 +- ...n-message-projection.recorded-copy.test.ts | 3 +- ...ssage-projection.rejected-in-place.test.ts | 89 +-- ...d-agent-session-message-projection.test.ts | 15 +- ...ctured-agent-session-message-projection.ts | 8 +- ...uctured-agent-session-queued-cards.test.ts | 15 + ...red-agent-session-transcript-order.test.ts | 4 +- ...ed-agent-session-delivery-notices.test.tsx | 3 - ...ructured-agent-session-delivery-notices.ts | 5 +- ...structured-agent-session-messages.test.tsx | 18 +- .../use-structured-agent-session-messages.ts | 7 +- ...ent-session-outbox.queue-delivery.test.tsx | 150 +++- ...ent-session-outbox.rejected-reply.test.tsx | 4 +- ...tructured-agent-session-queued-messages.ts | 15 +- ...tured-agent-session.rejected-card.test.tsx | 17 +- .../use-structured-agent-session.ts | 8 +- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + ...tructured-agent-session-host-capability.ts | 4 +- .../agent-session-journal-schemas.test.ts | 4 +- src/shared/agent-session-journal-schemas.ts | 34 +- ...-session-journal-submission-schema.test.ts | 43 ++ ...agent-session-journal-submission-schema.ts | 42 ++ src/shared/agent-session-journal-types.ts | 9 + .../agent-session-queued-message-wire.ts | 14 +- src/shared/protocol-version.ts | 12 +- ...d-agent-session-message-projection.test.ts | 85 +++ ...ctured-agent-session-message-projection.ts | 12 +- ...red-agent-session-outbox-reconcile.test.ts | 9 + ...ructured-agent-session-outbox-reconcile.ts | 7 + ...ructured-agent-session-send-disposition.ts | 8 + .../kept-card-downgrade.unit.test.ts | 227 ++++++ 109 files changed, 3314 insertions(+), 642 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-writer.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-positions.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-stored-row.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts create mode 100644 src/shared/agent-session-journal-submission-schema.test.ts create mode 100644 src/shared/agent-session-journal-submission-schema.ts create mode 100644 src/shared/structured-agent-session-message-projection.test.ts create mode 100644 tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index 57f2fa410ff..4b53ee81556 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -25,8 +25,8 @@ const RESUME_KEY = '\u0000resume' export type MobileNativeChatQueuedMessagesProps = { cards?: MobileQueuedMessageCard[] - /** Steer for a waiting card, the paused queue's included; plain Send for a card whose own send - * failed, or a returned one. */ + /** Steer for a waiting card, the paused queue's included; plain Send for a card held on its own + * (its send failed, or the host kept it unsent), or a returned one. */ onSend?: (messageId: string) => Promise onDelete?: (messageId: string) => Promise /** Copy the card's text into the composer, then delete the card. */ diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 3e037180530..90ebecb42eb 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -67,8 +67,8 @@ export type MobileNativeChatController = { }) => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void - /** Host-held queued drafts shown as cards above the composer (structured lane, - * capable host only; empty otherwise). */ + /** Host-held queued drafts shown as cards above the composer (structured lane; any host + * that publishes them). */ nativeChatQueued: MobileStructuredQueuedMessageControls nativeChatFilePaths: string[] loadNativeChatFiles: (query: string) => void diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 13604344789..01b8c7d4bc2 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -23,7 +23,7 @@ export type MobileNativeChatSendOrigin = { baselineTailMessageId: string | null baselineResolved: boolean /** Queued-draft cards already on screen at send time, so an earlier identical - * card cannot confirm this send. Structured lane on a queue-capable host only. */ + * card cannot confirm this send. Structured lane, on any host that publishes cards. */ baselineQueuedMessageIds?: readonly string[] } diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 44d1e0b9729..987d9aeefd4 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' +import { + QUEUED_MESSAGE_PAUSED_KEPT, + QUEUED_MESSAGE_PAUSED_SEND_FAILED +} from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import { mobileQueueHasResumableCard, @@ -99,6 +102,26 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) + // A kept card is held on its own, like a failed one: the drain goes past it. + const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) + expect(resumable([kept])).toBe(false) + expect(resumable([kept, waiting])).toBe(true) + }) + + // The host kept it unsent across a restart or a close; the cards behind it are not held by it. + it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => { + const cards = mobileQueuedMessageCards( + [ + draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), + draft({ messageId: 'b', position: 2 }) + ], + [], + { pendingPrompt: false } + ) + expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ + { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, + { caption: null, needsAttention: false } + ]) }) it('words the paused queue by reason, and one this build does not know as a plain pause', () => { diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index f3edb13e53c..843292e4421 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -8,6 +8,7 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { + QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuePause @@ -48,11 +49,15 @@ function returnedCaption( ) } -/** One card's own hold: only a failed conversion; the queue's pause is the list's first row. */ +/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the + * list's first row. */ function pausedCaption(reason: string | undefined): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return "Couldn't send — tap Send to retry" } + if (reason === QUEUED_MESSAGE_PAUSED_KEPT) { + return 'Not sent yet — tap Send to send it' + } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' } diff --git a/mobile/src/session/mobile-structured-send-delivery.test.ts b/mobile/src/session/mobile-structured-send-delivery.test.ts index 8431e32a7aa..278142b271c 100644 --- a/mobile/src/session/mobile-structured-send-delivery.test.ts +++ b/mobile/src/session/mobile-structured-send-delivery.test.ts @@ -125,6 +125,34 @@ describe('mobileStructuredSendDelivery', () => { }) }) + it('answers a send the host kept as a card like a queued one, first send or replay', () => { + // The card shows the text, so neither an error nor a composer hand-back may repeat it. + const kept: StructuredAgentSessionMutationCallResult = { + status: 'accepted', + value: { + clientMessageId: 'msg-1', + submission: { + clientMessageId: 'msg-1', + fence: 3, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this was sent.', + submittedAt: 10, + resolvedAt: 10, + keptAsQueuedMessageId: 'msg-1' + } + } + } + for (const retained of [false, true]) { + expect(mobileStructuredSendDelivery(kept, retained)).toEqual({ + outcome: 'queued', + operationIdSpent: true, + error: null + }) + } + }) + it('shows a provider content rejection verbatim', () => { expect( mobileStructuredSendDelivery(accepted('rejected', 'Claude does not support .bmp')) diff --git a/mobile/src/session/mobile-structured-send-delivery.ts b/mobile/src/session/mobile-structured-send-delivery.ts index f20dc097188..bc0db996a93 100644 --- a/mobile/src/session/mobile-structured-send-delivery.ts +++ b/mobile/src/session/mobile-structured-send-delivery.ts @@ -20,6 +20,7 @@ // and keeping the id would only refuse every later send of the same text: // a host that refuses the replay's request shape itself (an older host's // strict schema turning `delivery` away), and an id the host has expired. +// A rejection the host kept as a card answers as `queued`: the card holds the text. // unknown — the one answer that KEEPS its id, whether it came from the host or // from an ack-loss on the way back. The message may be with the provider, so // the retry has to stay a replay. Rotating here is what sent one message to a @@ -96,6 +97,11 @@ export function mobileStructuredSendDelivery( if (!submission || submission.dispatchState === 'unknown') { return { outcome: 'unknown', operationIdSpent: false, error: null } } + if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) { + // The host kept it as a card, which holds the text: no error, and nothing handed back to the + // composer, so the words never show twice. + return { outcome: 'queued', operationIdSpent: true, error: null } + } if (submission.dispatchState === 'rejected') { return { outcome: 'rejected', diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index 277beb17d75..fe7f7f1eb14 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -568,12 +568,28 @@ describe('mobile structured queued messages', () => { expect(hook!.queued.cards.map((card) => card.messageId)).toEqual(['same-id']) }) - it('shows no cards from an incapable host even if a list arrives', async () => { - await mountSession( - LEGACY, - snapshotEvent({ queuedMessages: [queuedDraft({ messageId: 'draft-1' })] }) + // A host that does not queue sends still keeps a message it accepted and never sent across a + // restart or a close, and publishes it as a card; only queueing a new send is gated. + it('shows the cards a host that does not queue sends publishes', async () => { + await mountSession(LEGACY) + act(() => + listener?.( + batchEvent( + [ + queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }), + queuedDraft({ messageId: 'behind', position: 2 }) + ], + [], + { reason: 'restarted' } + ) + ) ) - expect(hook!.queued.cards).toEqual([]) + expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([ + { messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' }, + { messageId: 'behind', caption: null } + ]) + // The kept card is held on its own, so Resume would send the card behind it. + expect(hook!.queued.pause).toEqual({ reason: 'restarted' }) }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 7bec053c9d6..1e1658511bc 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -61,7 +61,7 @@ type StructuredMobileSession = ReturnType Promise respondQuestion: (answer: string) => Promise cancelPrompt: (prompt?: { itemId: string; expectedRevision: number }) => Promise - /** The queued-draft cards and their actions; empty and inert off capable hosts. */ + /** The queued-draft cards and their actions, from any host that publishes them. */ queued: MobileStructuredQueuedMessageControls } @@ -97,7 +97,7 @@ export function useMobileStructuredAgentSession(args: { onSendError, hostSupport } = args - // Old host ⇒ exactly today's behavior: no delivery field, no cards, plain Stop. + // Only a host that queues sends gets the delivery field; any host's published cards show. const queueCapable = hostSupport?.queuedMessages === true const promptCancelSupported = hostSupport?.promptCancel ?? null const hostAnswersRepeatedStops = hostSupport?.quietRepeatedStop ?? null @@ -196,7 +196,6 @@ export function useMobileStructuredAgentSession(args: { [state.items] ) const queued = useMobileStructuredQueuedMessageControls({ - queueCapable, sessionKey, queuedMessages, queuePause, diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index 4e033c6dd97..f65ecf2d46e 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -1,8 +1,10 @@ // The queued-draft surface the structured session exposes: cards derived from -// the published list and the Send-now / Delete / Edit actions. All of it is -// gated on the host capability — an incapable host gets no cards and no new -// fields. Nothing here is durable: the host owns the queue, and the published -// list is the only truth a card action ever needs. +// the published list and the Send-now / Delete / Edit actions. Shown whatever +// the queue capability says: a host that does not queue sends still publishes a +// message it kept across a restart or a close, and only queueing a new send is +// gated. A host older than the queue publishes no list, so shows no cards. +// Nothing here is durable: the host owns the queue, and the published list is +// the only truth a card action ever needs. import { useCallback, useMemo } from 'react' import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' @@ -26,7 +28,7 @@ import type { MobileStructuredAgentMutate } from './use-mobile-structured-agent- export type MobileQueuedMessageEdit = (messageId: string, onCopied?: () => void) => Promise export type MobileStructuredQueuedMessageControls = { - /** Host-held drafts as cards above the composer; empty off capable hosts. */ + /** Host-held drafts as cards above the composer. */ cards: MobileQueuedMessageCard[] /** Send-now: dispatch this draft into or ahead of the running turn. */ send: (messageId: string) => Promise @@ -43,7 +45,6 @@ export type MobileStructuredQueuedMessageControls = { } export function useMobileStructuredQueuedMessageControls(args: { - queueCapable: boolean sessionKey: string queuedMessages: MobileQueuedMessageFeed queuePause: MobileQueuePause @@ -63,7 +64,6 @@ export function useMobileStructuredQueuedMessageControls(args: { onActionResolved, onSendError, pendingPrompt, - queueCapable, queuedMessages, queuePause, sessionKey, @@ -71,13 +71,11 @@ export function useMobileStructuredQueuedMessageControls(args: { } = args const cards = useMemo( () => - queueCapable - ? mobileQueuedMessageCards(queuedMessages, submissions, { - pendingPrompt, - queuePaused: queuePause !== null - }) - : [], - [pendingPrompt, queueCapable, queuePause, queuedMessages, submissions] + mobileQueuedMessageCards(queuedMessages, submissions, { + pendingPrompt, + queuePaused: queuePause !== null + }), + [pendingPrompt, queuePause, queuedMessages, submissions] ) const resolved = useCallback( (accepted: boolean): boolean => { @@ -160,6 +158,6 @@ export function useMobileStructuredQueuedMessageControls(args: { [mutate, resolved] ) // The header shows only while Resume would send something, as on desktop. - const pause = queueCapable && mobileQueueHasResumableCard(cards) ? queuePause : null + const pause = mobileQueueHasResumableCard(cards) ? queuePause : null return { cards, send, delete: deleteDraft, edit, pause, resume, sessionKey } } 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 3625982a24f..519a62fb3e1 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 @@ -35,6 +35,13 @@ export function applyJournalDispatchRow( } else { delete submission.rejection } + if ( + row.state === 'rejected' && + typeof row.keptAsQueuedMessageId === 'string' && + row.keptAsQueuedMessageId.length > 0 + ) { + submission.keptAsQueuedMessageId = row.keptAsQueuedMessageId + } if (row.state === 'rejected' && row.answeredInTurn !== undefined) { submission.answeredInTurn = readAnsweredTurn(row.answeredInTurn) } else { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 7f9711151fc..7c6deabef40 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -10,27 +10,34 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -function settle(kind: SubmissionRejectionKind) { - return rejectedDraftSettlement( - agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) - ) +function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') { + return rejectedDraftSettlement({ + ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }), + origin + }) } describe('what a rejection does to the draft it was consumed from', () => { it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { - expect(settle('cancelled')).toEqual({ state: 'waiting' }) + expect(settle('cancelled')).toEqual({ state: 'waiting', kept: false }) expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting' + state: 'waiting', + kept: false }) + expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) }) - it('a restart or close before hand-over sends it back to waiting too', () => { - for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) { - expect(settle(kind)).toEqual({ state: 'waiting' }) + // A Send the person asked for and the host never handed over waits for them; the queue's own + // hand-off, or one from a build that recorded no origin, waits under the queue's pause. + it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => { + for (const kind of ['hostRestarted', 'chatClosed'] as const) { + expect(settle(kind, 'client')).toEqual({ state: 'waiting', kept: true }) + expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false }) + expect(settle(kind)).toEqual({ state: 'waiting', kept: false }) } - expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ - state: 'waiting' - }) + expect( + rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' }) + ).toEqual({ state: 'waiting', kept: true }) }) it('a failure returns the card for the user to act on', () => { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 7e1d6949bf3..6b3e53f36b3 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -30,22 +30,27 @@ export function consumedSubmissionWasRejected( } /** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' } +export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } /** - * Where no one failed the user — a Stop withdrew it, or a restart or close - * interrupted it before hand-over — the draft goes back to waiting at its own - * position, under whatever pauses the queue: the Stop's own pause, or the - * restart's, derived from the host instance. A returned card would block the - * drafts behind it on a failure that never happened. A failure returns the - * card with its refusal for the user to act on. + * Where no one failed the user, the draft goes back to waiting at its own position, under whatever + * pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person + * asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send + * it again, as the host keeps every message a person sent and it never handed over; the queue's + * own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after + * a restart, and plainly queued after a close in the same process. A returned card would block the drafts + * behind it on a failure that never happened. A failure returns the card with its refusal for the + * user to act on. */ export function rejectedDraftSettlement( - rejection: Pick & { rejection?: unknown } + rejected: Pick & { rejection?: unknown } ): RejectedDraftSettlement { - return classifyDispatchRejection(rejection).verdict === null - ? { state: 'waiting' } - : { state: 'returned' } + const { verdict, kind } = classifyDispatchRejection(rejected) + if (verdict !== null) { + return { state: 'returned' } + } + const cutShort = kind === 'hostRestarted' || kind === 'chatClosed' + return { state: 'waiting', kept: cutShort && rejected.origin === 'client' } } /** True when committing this row NEWLY settles the submission to `rejected` — diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 8c190196bdf..902f565b617 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -35,6 +35,7 @@ import { } from './queued-message-table' import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' import { queuedMessageSettlementOwed, @@ -72,6 +73,10 @@ export class JournalQueuedMessages { constructor(private readonly deps: JournalQueuedMessagesDeps) {} + get sessionId(): string { + return this.deps.sessionId + } + revision(): number { return this.changeRevision } @@ -102,8 +107,9 @@ export class JournalQueuedMessages { return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) } - /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused. - * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ + /** `carriedFrom`: a /clear's carry. The card is its own 'cleared' pause, so it lands paused; + * `holdReason` carries a hold of its own over with it. `receipt`: the send's ledger answer, + * committed with the draft only when this inserts it. */ insert( input: { messageId: string @@ -112,6 +118,7 @@ export class JournalQueuedMessages { hostInstance: string carriedFrom?: string source: AgentSessionMessageSource + holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt ): Promise { @@ -193,6 +200,26 @@ export class JournalQueuedMessages { ).then((changed) => changed > 0) } + /** Inside the caller's journal-row transaction (`journal-unsent-send-hold.ts`): one kept send + * becomes a card, and the cards ahead of the queue take the positions given. False when a card + * by that id already exists, which then stands. */ + holdInTransaction( + db: Database.Database, + input: { + card: Omit[1], 'sessionId' | 'now'> | null + positions: readonly QueuedMessagePositionMove[] + } + ): boolean { + const { sessionId } = this.deps + this.changeRevision += moveQueuedMessages(db, sessionId, input.positions) + if (!input.card || getQueuedMessage(db, sessionId, input.card.messageId)) { + return false + } + insertQueuedMessage(db, { ...input.card, sessionId, now: this.deps.now() }) + this.changeRevision++ + return true + } + /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, * kept only so a replay of the settling operation answers "spent". */ withdraw(input: { diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 36a7d7262e8..cba9c9a9bd5 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -8,6 +8,7 @@ import type { AgentSessionJournalIdentity, AgentSessionJournalProviderHandle } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { agentSessionJournalProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import { @@ -112,6 +113,9 @@ export function journalDispatchRowBuilder( providerItemId, reason: boundedDispatchReason(input), ...(input.state === 'rejected' ? { rejection: input.rejection } : {}), + ...(input.state === 'rejected' && input.keptAsQueuedMessageId !== undefined + ? { keptAsQueuedMessageId: input.keptAsQueuedMessageId } + : {}), // Every rejection states its turn, null for none, so a reader tells it from an older row. ...(input.state === 'rejected' ? { @@ -321,6 +325,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + source?: Pick }): JournalSubmissionRow { return { kind: 'submission', @@ -331,6 +336,8 @@ export function buildJournalSubmissionRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), - ...(input.origin !== undefined ? { origin: input.origin } : {}) + ...(input.origin !== undefined ? { origin: input.origin } : {}), + // The kind only: a caller's full source carries senders that stay host-only. + ...(input.source !== undefined ? { source: { kind: input.source.kind } } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 9d436e1eb54..d2a95a6db72 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -144,6 +144,9 @@ export type JournalSubmissionRow = JournalRowBase & { * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it * was recorded. Older readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin + /** Who it is from: its `AgentSessionMessageSource`'s kind only (`AgentJournalSubmission`). + * Absent on rows from before it was recorded. Older readers keep the key and ignore it. */ + source?: { kind: string } } export type JournalSubmissionOrigin = 'client' | 'host' @@ -160,6 +163,9 @@ export type JournalDispatchRow = JournalRowBase & { /** On `rejected`: why, typed. Older readers keep the key and ignore it; a malformed one is * dropped when read, never the row. */ rejection?: AgentSessionFailureFact + /** On `rejected`: the card the host kept this send as (`AgentJournalSubmission`). Older readers + * keep the key and ignore it. */ + keptAsQueuedMessageId?: string /** On `rejected`: the turn a Codex send was answered into, and how it joined it, when that * turn's end settled the send; null on every other rejection. Absent on other rows and on rows * written before it. `via` stays a string: a newer build may write another. Older readers keep 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 1995b2e9875..b4dbe3735ba 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 @@ -19,6 +19,7 @@ import type { JournalReducerState } from './journal-reducer' import { JournalRowWriter } from './journal-row-writer' import { JournalStepWriter } from './journal-step-writer' import { restoreJournalStore } from './journal-store-restore' +import { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' import type { JournalWriteBody } from './journal-write-queue' @@ -53,6 +54,7 @@ export type JournalStoreCollaborators = { epochController: JournalEpochController itemAppender: JournalItemAppender lifecycleBatchAppender: JournalLifecycleBatchAppender + submissionWriter: JournalSubmissionWriter stepWriter: JournalStepWriter queuedMessages: JournalQueuedMessages stopMarks: JournalStopMarks @@ -119,6 +121,12 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuedMessages.repairAndPruneAtOpen() ), rowWriter, + submissionWriter: new JournalSubmissionWriter({ + state: host.state, + identity: host.identity, + rowWriter, + queuedMessages + }), itemAppender: new JournalItemAppender({ state: host.state, enqueue: host.enqueue 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 c2547c658c8..2d7b56d06b1 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 @@ -11,6 +11,7 @@ import type { AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' @@ -43,6 +44,7 @@ export type ResolveDispatchInput = { * `agentSessionFailureWords`, never written by hand. */ | ({ state: 'rejected' + keptAsQueuedMessageId?: string answeredInTurn?: AgentJournalAnsweredTurnIdentity } & AgentJournalDispatchRejection) | { state: 'unknown'; reason?: string | null } @@ -99,6 +101,8 @@ export type JournalSubmissionInput = { queuedMessageId?: string /** Who asked for this turn (`JournalSubmissionRow.origin`). */ origin?: 'client' | 'host' + /** Who it is from (`JournalSubmissionRow.source`); the row keeps the kind only. */ + source?: Pick } /** A submission append that converts a queued draft, in one transaction. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 626b77cf614..9b676cb26b3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -41,11 +41,7 @@ import { resolveJournalItemId, type JournalReducerState } from './journal-reducer' -import { - journalDispatchRowBuilder, - journalSubmissionRowBuilder, - journalTombstoneRowBuilder -} from './journal-row-builders' +import { journalTombstoneRowBuilder } from './journal-row-builders' import type { AgentSessionJournalOptions, JournalAppendResult, @@ -58,13 +54,18 @@ import type { JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' +import type { + JournalOperationReceipt, + JournalRowTransactionHook, + JournalRowWriter +} from './journal-row-writer' +import type { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' @@ -89,6 +90,7 @@ export class AgentSessionJournal { private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender + private readonly submissionWriter: JournalSubmissionWriter private readonly stepWriter: JournalStepWriter private readonly restore: () => Promise /** Draft rows queued while the agent works; never reducer input or owed work. */ @@ -132,6 +134,7 @@ export class AgentSessionJournal { this.epochController = collaborators.epochController this.itemAppender = collaborators.itemAppender this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender + this.submissionWriter = collaborators.submissionWriter this.stepWriter = collaborators.stepWriter this.queuedMessages = collaborators.queuedMessages this.stopMarks = collaborators.stopMarks @@ -313,35 +316,21 @@ export class AgentSessionJournal { /** Several writes as one turn in the queue; see `JournalStepWriter`. */ appendSteps: JournalStepWriter['append'] = (steps) => this.stepWriter.append(steps) - /** - * Write-ahead submission row. It is durable before the caller dispatches - * anything, and it doubles as the optimistic user bubble so an accepted echo - * reconciles into an existing slot instead of appending a second copy. - */ + /** The write-ahead submission row (`JournalSubmissionWriter.append`). */ appendSubmission( input: JournalSubmissionInput, - /** Present: this submission is a queued draft's conversion, and the draft's - * state transition commits in the SAME transaction — exactly-once consume. */ consume?: JournalSubmissionConsume, - /** The send's ledger answer, committed with this row. */ receipt?: JournalOperationReceipt ): Promise { - return this.rowWriter.append( - journalSubmissionRowBuilder(() => this.state, this.identity, input, consume), - consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume), - receipt - ) + return this.submissionWriter.append(input, consume, receipt) } - /** - * Record a dispatch transition, including a proven retry returning to pending. - * - * Accepting REQUIRES the provider identity rather than a free-form id: the - * adopted key is what the provider's echo will upsert into, so a mismatched - * string here would silently give the user a second copy of their own message. - */ - resolveDispatch(input: ResolveDispatchInput): Promise { - return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input)) + /** A dispatch transition (`JournalSubmissionWriter.resolveDispatch`). */ + resolveDispatch( + input: ResolveDispatchInput, + hook?: JournalRowTransactionHook + ): Promise { + return this.submissionWriter.resolveDispatch(input, hook) } /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ 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 b00bdd36de2..b93ad8c6fcf 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 @@ -31,7 +31,10 @@ export function applyJournalSubmission( ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 ? { queuedMessageId: row.queuedMessageId } : {}), - ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), + // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither + // reads as a row without one. + ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. @@ -134,3 +137,13 @@ export function notePersonTurnAccepted( ) } } + +/** A stored source's kind; an undecodable value reads as an empty kind, never a person's. */ +function storedSourceKind(stored: unknown): string { + return typeof stored === 'object' && + stored !== null && + 'kind' in stored && + typeof stored.kind === 'string' + ? stored.kind + : '' +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index d0306fa9467..dda3cd71695 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -6,7 +6,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-schemas' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' import type { AgentJournalMessageItem, AgentSessionJournalIdentity diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts new file mode 100644 index 00000000000..c79fa603031 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -0,0 +1,67 @@ +// A sent message's rows: its write-ahead submission and each dispatch transition, with what must +// commit in the same transaction (a queued draft's consume, the send's ledger answer, a kept card). + +import type { + AgentJournalCursor, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalReducerState } from './journal-reducer' +import { journalDispatchRowBuilder, journalSubmissionRowBuilder } from './journal-row-builders' +import type { + JournalOperationReceipt, + JournalRowTransactionHook, + JournalRowWriter +} from './journal-row-writer' +import type { + JournalSubmissionConsume, + JournalSubmissionInput, + ResolveDispatchInput +} from './journal-store-contracts' + +export type JournalSubmissionWriterDeps = { + state: () => JournalReducerState + identity: AgentSessionJournalIdentity + rowWriter: JournalRowWriter + queuedMessages: JournalQueuedMessages +} + +export class JournalSubmissionWriter { + constructor(private readonly deps: JournalSubmissionWriterDeps) {} + + /** + * Write-ahead submission row. It is durable before the caller dispatches + * anything, and it doubles as the optimistic user bubble so an accepted echo + * reconciles into an existing slot instead of appending a second copy. + */ + append( + input: JournalSubmissionInput, + /** Present: this submission is a queued draft's conversion, and the draft's + * state transition commits in the SAME transaction — exactly-once consume. */ + consume?: JournalSubmissionConsume, + /** The send's ledger answer, committed with this row. */ + receipt?: JournalOperationReceipt + ): Promise { + const { identity, queuedMessages, rowWriter, state } = this.deps + return rowWriter.append( + journalSubmissionRowBuilder(state, identity, input, consume), + consume && queuedMessageConsumeHook(queuedMessages, input.clientMessageId, consume), + receipt + ) + } + + /** + * Record a dispatch transition, including a proven retry returning to pending. + * + * Accepting REQUIRES the provider identity rather than a free-form id: the + * adopted key is what the provider's echo will upsert into, so a mismatched + * string here would silently give the user a second copy of their own message. + * `hook` runs in the row's transaction: it commits with the row, or rolls it back by throwing. + */ + resolveDispatch( + input: ResolveDispatchInput, + hook?: JournalRowTransactionHook + ): Promise { + return this.deps.rowWriter.append(journalDispatchRowBuilder(this.deps.state, input), hook) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts new file mode 100644 index 00000000000..f559f07db12 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -0,0 +1,486 @@ +// Which unsent sends a restart or a close keeps as held cards, where they go in the queue, and that +// a send's source is recorded and folded. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + USER_MESSAGE_SOURCE, + type AgentMessageSource, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' +import { + holdUnsentSends, + unsentSendKeptAsCard, + type UnsentSendHold +} from './journal-unsent-send-hold' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-held', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) +} +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) +/** The live host process the cards are written for. */ +const HOST = 'host-instance-2' + +let root: string +let clock = 1_000 +let epochs = 0 +const journals = createTrackedJournalOpener() + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function fingerprint(body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body } + }) +} + +async function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${(epochs += 1)}` + }) +} + +function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'hostRestarted' }) { + return holdUnsentSends(journal, { fence: 0, hostInstance: HOST, hold: settle }) +} + +/** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ +const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [{ party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null } }], + orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } +} + +async function accept( + journal: AgentSessionJournal, + id: string, + fields: { + body?: AgentJournalMessageItem + origin?: 'client' | 'host' + source?: AgentSessionMessageSource + } = {} +): Promise { + const body = fields.body ?? message(`text of ${id}`) + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: fingerprint(body), + body, + fence: 0, + handoverRecorded: true, + ...(fields.origin ? { origin: fields.origin } : {}), + ...(fields.source ? { source: fields.source } : {}) + }) +} + +/** Writes rows as a process that then quit, and opens the journal again as the next one. */ +async function afterRestart( + write: (journal: AgentSessionJournal) => Promise +): Promise { + const earlier = await open() + await write(earlier) + await earlier.close() + return open() +} + +/** Rewrites a stored submission's `source` as another build would have written it. */ +function storeSubmissionSourceAs(journal: AgentSessionJournal, id: string, source: unknown): void { + const seq = journal.submission(id)?.submittedSequence + const { db } = openTestJournalHostDatabase(root) + const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === seq) + if (!stored) { + throw new Error(`no stored row for ${id}`) + } + const row: unknown = JSON.parse(stored.rowJson) + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + stored.seq, + JSON.stringify({ ...(typeof row === 'object' ? row : {}), source }) + ) +} + +function cardOrder(journal: AgentSessionJournal): string[] { + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => card.messageId) +} + +beforeEach(async () => { + epochs = 0 + root = await mkdtemp(join(tmpdir(), 'orca-unsent-hold-')) +}) + +afterEach(async () => { + await journals.closeAll() + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('which sends an earlier host process left unsent are kept', () => { + it('keeps a person’s and a launch’s text, and an older build’s client send', async () => { + const journal = await afterRestart(async (earlier) => { + await accept(earlier, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'launch', { origin: 'host', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'legacy-client', { origin: 'client' }) + }) + await hold(journal) + + expect(cardOrder(journal)).toEqual(['person', 'launch', 'legacy-client']) + await journal.close() + // The rejection names the card it was kept as, read back from disk. + const reopened = await open() + for (const id of ['person', 'launch', 'legacy-client']) { + expect(reopened.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + keptAsQueuedMessageId: id + }) + expect(reopened.queuedMessages.get(id)).toMatchObject({ + state: 'waiting', + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: HOST, + body: message(`text of ${id}`), + fingerprint: fingerprint(message(`text of ${id}`)), + carriedFrom: null, + queuedAt: { epoch: 'epoch-1', sequence: reopened.submission(id)?.acceptedSequence } + }) + } + }) + + it('rejects mail, a dispatch preamble, a continuation, /compact, an image, and a send no one can attribute', async () => { + const image: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'image-ref', path: '/tmp/attachment.png' } + ] + } + const journal = await afterRestart(async (earlier) => { + await accept(earlier, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await accept(earlier, 'dispatch', { origin: 'host' }) + await accept(earlier, 'continuation', { origin: 'host' }) + await accept(earlier, 'compact', { + origin: 'client', + source: USER_MESSAGE_SOURCE, + body: structuredAgentSessionCompactBody() + }) + await accept(earlier, 'image', { origin: 'client', source: USER_MESSAGE_SOURCE, body: image }) + await accept(earlier, 'legacy-host', { origin: 'host' }) + await accept(earlier, 'no-origin') + }) + await hold(journal) + + expect(journal.queuedMessages.list()).toEqual([]) + for (const id of [ + 'mail', + 'dispatch', + 'continuation', + 'compact', + 'image', + 'legacy-host', + 'no-origin' + ]) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(journal.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') + } + }) + + // Only a Send the person asked for comes back kept; the queue's own hand-off waits under the + // restart's pause, where it stood. + it.each([ + { by: 'Send now', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { by: 'the queue', origin: 'host' as const, holdReason: null } + ])( + 'a card’s own hand-off by $by returns its card, and makes no second one', + async ({ origin, holdReason }) => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'card', + body: message('card text'), + fingerprint: fingerprint(message('card text')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await earlier.appendSubmission( + { + clientMessageId: 'handoff', + payloadFingerprint: fingerprint(message('card text')), + body: message('card text'), + fence: 0, + handoverRecorded: true, + origin + }, + { messageId: 'card', expect: 'waiting', settledByOp: null } + ) + }) + await hold(journal) + + expect(journal.queuedMessages.list()).toHaveLength(1) + expect(journal.queuedMessages.get('card')).toMatchObject({ + state: 'waiting', + consumedAs: null, + holdReason + }) + expect(journal.queuedMessages.get('handoff')).toBeNull() + } + ) + + it('leaves alone what this process accepted', async () => { + const journal = await open() + await accept(journal, 'mine', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await hold(journal) + expect(journal.submission('mine')?.dispatchState).toBe('pending') + expect(journal.queuedMessages.list()).toEqual([]) + }) + + it('never keeps a queue hand-off, a card’s link, or a send with no message body', () => { + const body = message('x') + expect(unsentSendKeptAsCard({ source: USER_MESSAGE_SOURCE }, body)).toBe(body) + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'agent' } }, body)).toBeNull() + // Undecodable (folded as an empty kind) and unknown kinds are never a person's. + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: '' } }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'orca' } }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client', queuedMessageId: 'card' }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client' }, null)).toBeNull() + }) + + // Only a row with no source at all is an older build's; a newer build's kind may name a sender + // that must not become a card. + it('never keeps a source kind this build does not know, whatever its origin', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.appendSubmission({ + clientMessageId: 'future', + payloadFingerprint: fingerprint(message('from a newer build')), + body: message('from a newer build'), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) + storeSubmissionSourceAs(earlier, 'future', { kind: 'a-newer-kind' }) + }) + expect(journal.submission('future')?.source).toEqual({ kind: 'a-newer-kind' }) + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('future')).toMatchObject({ dispatchState: 'rejected' }) + }) + + // A source with no readable kind is not a row without one: an older build's rule never applies. + it('never keeps a send whose stored source has no readable kind', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.appendSubmission({ + clientMessageId: 'unreadable', + payloadFingerprint: fingerprint(message('unreadable')), + body: message('unreadable'), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) + storeSubmissionSourceAs(earlier, 'unreadable', {}) + }) + expect(journal.submission('unreadable')?.source).toEqual({ kind: '' }) + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('unreadable')).toMatchObject({ dispatchState: 'rejected' }) + }) +}) + +describe('where kept sends go in the queue', () => { + // A Send the person asked for comes back kept among them; the queue's own hand-off stays put. + it.each([ + { by: 'Send now', origin: 'client' as const, order: ['A', 'H', 'B', 'C'] }, + { by: 'the queue', origin: 'host' as const, order: ['A', 'B', 'H', 'C'] } + ])( + 'in acceptance order, ahead of the cards already waiting, a hand-off by $by among them', + async ({ origin, order }) => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'H', + body: message('H'), + fingerprint: fingerprint(message('H')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await earlier.appendSubmission( + { + clientMessageId: 'H-handoff', + payloadFingerprint: fingerprint(message('H')), + body: message('H'), + fence: 0, + handoverRecorded: true, + origin + }, + { messageId: 'H', expect: 'waiting', settledByOp: null } + ) + await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + await hold(journal) + + expect(cardOrder(journal)).toEqual(order) + } + ) + + it('a run a crash cut short is placed again with the rest, in acceptance order', async () => { + const interrupted = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1', + source: USER_MESSAGE_SOURCE + }) + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + // That run kept A, at a stale place behind C, then died before B. + await interrupted.resolveDispatch( + { clientMessageId: 'A', state: 'rejected', ...HOST_RESTARTED, fence: 0, recovered: true }, + (db) => { + interrupted.queuedMessages.holdInTransaction(db, { + card: { + messageId: 'A', + body: message('text of A'), + fingerprint: fingerprint(message('text of A')), + hostInstance: HOST, + source: USER_MESSAGE_SOURCE, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + queuedAt: { + epoch: 'epoch-1', + sequence: interrupted.submission('A')!.acceptedSequence! + }, + position: 5 + }, + positions: [] + }) + } + ) + await interrupted.close() + const journal = await open() + await hold(journal) + + expect(cardOrder(journal)).toEqual(['A', 'B', 'C']) + }) +}) + +describe('a close of the chat', () => { + it('keeps a person’s unsent send in place, rejected as closed, by the same rule as a restart', async () => { + const journal = await open() + await accept(journal, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await hold(journal, { cause: 'chatClosed' }) + + expect(cardOrder(journal)).toEqual(['person']) + expect(journal.queuedMessages.get('person')).toMatchObject({ + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: HOST + }) + for (const id of ['person', 'mail']) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + } + }) + + it('settles only what `which` names, leaving a later send queued', async () => { + const journal = await open() + await accept(journal, 'before', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'after', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await hold(journal, { + cause: 'chatClosed', + which: (submission) => submission.clientMessageId === 'before' + }) + + expect(cardOrder(journal)).toEqual(['before']) + expect(journal.submission('after')?.dispatchState).toBe('pending') + }) +}) + +describe('the order kept cards stand in', () => { + // Sequences restart with each epoch, so a card kept before a rewind has no sequence to compare. + it('a card kept before the epoch rolled stays ahead of one kept after it', async () => { + const journal = await afterRestart(async (earlier) => { + for (const id of ['m1', 'm2', 'm3']) { + await accept(earlier, id, { origin: 'host', source: MAIL_SOURCE }) + } + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + await hold(journal) + await journal.rollEpoch('handle_forked', 0) + await accept(journal, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await journal.close() + const reopened = await open() + await hold(reopened) + + expect(reopened.queuedMessages.get('A')?.queuedAt?.epoch).not.toBe( + reopened.queuedMessages.get('B')?.queuedAt?.epoch + ) + expect(cardOrder(reopened)).toEqual(['A', 'B']) + }) +}) + +describe('the source a send records', () => { + it('is folded from the row; a row without one folds without it', async () => { + const journal = await open() + await accept(journal, 'with-source', { origin: 'host', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'without', { origin: 'client' }) + const folded: AgentJournalSubmission | undefined = journal.submission('with-source') + expect(folded?.source).toEqual({ kind: 'user' }) + expect(journal.submission('without')).not.toHaveProperty('source') + }) + + // Who sent it stays on the card, host-only; a submission is published to clients as it folds. + it('keeps only the kind of an agent’s source, never its senders', async () => { + const journal = await open() + await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await journal.close() + const reopened = await open() + expect(reopened.submission('mail')?.source).toEqual({ kind: 'agent' }) + expect(JSON.stringify(reopened.submission('mail'))).not.toContain('agent:coordinator') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts new file mode 100644 index 00000000000..f08d8bb05c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -0,0 +1,208 @@ +// What becomes of a send the host accepted and can no longer hand over: an earlier host process +// quit or crashed first (settled at the next open), or the chat is closing. Either way the agent +// provably never got it. A person's message (typed, or a launch's first prompt) is kept as a card +// at the head of the queue, held until the person sends, edits or deletes it (`kept`). Everything +// else is rejected as before, because something else re-derives it or the person re-runs it. The +// submission itself is always rejected, so it is never handed over twice. + +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { AgentSessionJournal } from './journal-store' +import type { JournalRowTransactionHook } from './journal-row-writer' +import type { QueuedMessagePositionMove } from './queued-message-positions' + +/** Why the host can no longer hand a send over, which also says which sends it is. */ +export type UnsentSendHold = + /** At open, and the delivery loop's first step: what an earlier host process accepted. */ + | { cause: 'hostRestarted' } + /** A close of the chat; `which` narrows it to what a close that did not complete closed. */ + | { cause: 'chatClosed'; which?: (submission: AgentJournalSubmission) => boolean } + +/** The body an unsent send is kept with, or null when it is rejected instead: + * - a card's own hand-off: its rejection already returns the card (`rejectedDraftSettlement`); + * - `/compact` and other commands: a command in flight is not resumed, the person re-runs it; + * - an image: cards are text-only; + * - a send not from a person: orchestration mail (the mailbox re-sends it), a dispatch preamble + * or a restart continuation (their owners re-derive them), a kind this build does not know, and + * a row with no source that is not a person's (it could be either). */ +export function unsentSendKeptAsCard( + submission: Pick, + body: AgentJournalItemBody | null +): AgentJournalMessageItem | null { + if (submission.queuedMessageId !== undefined || body?.kind !== 'message' || body.command) { + return null + } + if (!body.blocks.every((block) => block.type === 'text')) { + return null + } + const { source } = submission + // Exactly 'user': never `readAgentSessionMessageSource`, whose fallback for what it cannot read + // is the person. + const persons = + source?.kind === USER_MESSAGE_SOURCE.kind || + // A build before `source` was recorded: `client` was only ever a person's send. + (source === undefined && submission.origin === 'client') + return persons ? body : null +} + +/** + * Settles every send `hold` names. Each is rejected with the hold's cause in its own row, and a + * kept one becomes a card in that row's transaction, so a crash between them can never leave + * both. This batch's cards, and the card of a Send the person asked for, go to the head of the + * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's + * own hand-off returns its card where it stood. Throws once every row was + * tried when one of them could not be written at all: that row stays queued. + */ +export async function holdUnsentSends( + journal: AgentSessionJournal, + input: { fence: number; hostInstance: string; hold: UnsentSendHold } +): Promise { + const { hold } = input + const unsent = journal + .submissions() + .filter( + (entry) => + isQueuedAgentJournalSubmission(entry) && + (hold.cause === 'hostRestarted' + ? journal.wroteBeforeOpen(entry.acceptedSequence) + : (hold.which?.(entry) ?? true)) + ) + .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) + if (unsent.length === 0) { + return + } + const { epoch } = journal.cursor() + const kept = unsent.map((submission) => ({ + submission, + body: unsentSendKeptAsCard( + submission, + journal.itemBody(agentJournalSubmissionKey(submission.clientMessageId)) + ) + })) + const positions = headOfQueuePositions(journal, kept) + const rejection = agentSessionFailureWords(agentSessionFailureFact(hold.cause), { + surface: 'rejection' + }) + const failures: unknown[] = [] + for (const [index, { submission, body }] of kept.entries()) { + const { clientMessageId } = submission + const moves = [ + // The cards an earlier settlement kept move with the first row. + ...(index === 0 ? positions.earlier : []), + ...(submission.queuedMessageId !== undefined + ? positions.placed.filter( + (entry) => 'consumedAs' in entry && entry.consumedAs === clientMessageId + ) + : []) + ] + const position = positions.placed.find( + (entry) => 'messageId' in entry && entry.messageId === clientMessageId + )?.position + const card = body && position !== undefined ? { body, position } : null + const keep: JournalRowTransactionHook | undefined = + card || moves.length > 0 + ? (db) => { + journal.queuedMessages.holdInTransaction(db, { + card: card + ? { + messageId: clientMessageId, + body: card.body, + fingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: journal.queuedMessages.sessionId, + fields: { body: card.body } + }), + hostInstance: input.hostInstance, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + // Only a person's send is kept. + source: USER_MESSAGE_SOURCE, + queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, + position: card.position + } + : null, + positions: moves + }) + } + : undefined + const reject = { + clientMessageId, + state: 'rejected' as const, + ...rejection, + fence: input.fence, + recovered: true as const + } + try { + // The send names its card in the same row, so no surface draws it once the card is gone. + await journal.resolveDispatch( + card ? { ...reject, keptAsQueuedMessageId: clientMessageId } : reject, + keep + ) + } catch (error) { + if (!keep) { + failures.push(error) + continue + } + // Keeping it failed: rejected as before. That loses the message, as every build before this + // one did; it is never left queued for a handover nothing will make. + console.warn('[journal-hold] keeping an unsent send failed:', { + sessionId: journal.queuedMessages.sessionId, + clientMessageId, + cause: hold.cause, + error: error instanceof Error ? error.message : String(error) + }) + await journal.resolveDispatch(reject).catch((fallback: unknown) => failures.push(fallback)) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'settling unsent sends failed') + } +} + +/** Where each card of the batch goes: right before every other card, the cards an earlier + * settlement kept first, in the order they stand, then this batch's cards and the cards of the + * person's own Sends in the order they were accepted. A kept card's `queuedAt` cannot order it against + * them: sequences restart with each epoch, and a returned hand-off's names its draft's time. */ +function headOfQueuePositions( + journal: AgentSessionJournal, + batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] +): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { + const cards = journal.queuedMessages.list() + const earlier = cards + .filter((card) => card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + .map((card) => card.messageId) + const placed: QueuedMessagePositionMove[] = [] + for (const { submission, body } of batch) { + const { clientMessageId } = submission + if (body && !earlier.includes(clientMessageId)) { + placed.push({ messageId: clientMessageId, position: 0 }) + } else if ( + // Kept by its settlement (`rejectedDraftSettlement`): a Send the person asked for. + submission.origin === 'client' && + cards.some((card) => card.consumedAs === clientMessageId) + ) { + placed.push({ consumedAs: clientMessageId, position: 0 }) + } + } + const inHead = (card: (typeof cards)[number]): boolean => + earlier.includes(card.messageId) || + placed.some((move) => + 'messageId' in move ? move.messageId === card.messageId : move.consumedAs === card.consumedAs + ) + const others = cards.filter((card) => !inHead(card)).map((card) => card.position) + const anchor = others.length > 0 ? Math.min(...others) : 1 + const first = anchor - earlier.length - placed.length + return { + earlier: earlier.map((messageId, index) => ({ messageId, position: first + index })), + placed: placed.map((move, index) => ({ ...move, position: first + earlier.length + index })) + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts index 00b90a832c1..05867436e12 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,6 +1,7 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A // Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); -// a per-draft hold is only a conversion that failed, which an explicit Send releases. +// a per-draft hold is a conversion that failed, or a send the host kept +// (`QueuedMessageHoldReason`), which an explicit Send releases. import type Database from '../../sqlite/sync-database' import type { QueuedMessageHoldReason } from './queued-message-table' diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 5989a10e967..f9bf0fb2d0b 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -29,6 +29,7 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { @@ -520,6 +521,24 @@ describe("a restart's pause", () => { ]) }) + it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { + const journal = await open() + const kept = await journal.queuedMessages.insert({ + messageId: 'kept', + body: message('kept across a restart'), + fingerprint: 'fp-kept', + hostInstance: 'proc-0', + source: { kind: 'user' }, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + expect(await journal.queuedMessages.adopt(HOST)).toBe(true) + expect(journal.queuedMessages.get('kept')).toMatchObject({ + hostInstance: HOST, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + }) + it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { const journal = await open() await queueDraft(journal, 'draft-1') @@ -596,6 +615,31 @@ describe('which cards the pauses in force hold', () => { expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') }) + // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered + // over the cards a pause holds behind it. + it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { + const behind = card('behind', 2) + const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] + expect(holding(kept, 0)).toEqual([ + ['kept', null], + ['behind', null] + ]) + expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) + expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() + const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] + expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() + expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') + }) + + // A dead process's card held on its own waits for its own Send, so it pauses no other card. + it('a card held on its own from a dead process starts no restart pause', () => { + for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { + const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] + expect(pausesOver(cards, 0)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') + } + }) + it("a /clear's pause that holds nothing never hides a restart's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 63dc6823df2..de3b702067c 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -5,8 +5,9 @@ // supersedes it; only a person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no person's turn or // Resume has happened here since. -// - 'restarted': a waiting card was written by another host process, and no person's turn has -// started since this conversation opened. +// - 'restarted': a waiting card with no hold of its own was written by another host process, and +// no person's turn has started since this conversation opened. +// A card held on its own (`hold_reason`) is outside every pause: only an action on it releases it. // A person's turn is an accepted submission of origin `client`. Orchestration mail, a restart // continuation, a launch prompt and the queue's own drain are `host` and never lift it. @@ -128,7 +129,11 @@ export function deriveQueuePauses(input: { if (carried.length > 0 && latestPersonTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } - if (!input.restartEnded && waiting.some((card) => card.hostInstance !== input.hostInstance)) { + // A card held on its own waits for its own Send whoever wrote it, so it pauses nothing else. + const foreign = waiting.some( + (card) => card.holdReason === null && card.hostInstance !== input.hostInstance + ) + if (!input.restartEnded && foreign) { // The process that wrote a card is gone: every card waits, whenever it was written. pauses.push({ reason: 'restarted', since: null }) } @@ -152,8 +157,8 @@ function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { } // Product decision: a card queued AFTER a Stop is a new instruction and is not held; only cards -// queued before it, and a steer it withdrew, wait. It still never jumps ahead of a held card: the -// drain stops at the first one. true instead holds every waiting card, whenever it was queued. +// queued before it, and a steer it withdrew, wait. It still never jumps ahead of one a pause holds: +// the drain stops at the first one. true instead holds every waiting card, whenever it was queued. const PAUSE_HOLDS_CARDS_QUEUED_AFTER_IT = false /** THE rule for which cards are held: by ANY pause in force, named by the first that holds it, so @@ -170,8 +175,9 @@ export function queuePauseHolding( } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a - * returned card or a held one comes first. The queue never reorders, so a newer card never - * overtakes a held one. The drain's pick and its consume both read this. */ + * returned card or one a pause holds comes first. The queue never reorders, so a newer card never + * overtakes one a pause holds; a card held on its own is passed over. The drain's pick and its + * consume both read this. */ export function nextSendableQueuedCard( pauses: readonly DerivedQueuePause[], cards: readonly T[] diff --git a/src/main/native-chat/agent-session-journal/queued-message-positions.ts b/src/main/native-chat/agent-session-journal/queued-message-positions.ts new file mode 100644 index 00000000000..9916e6fc31b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-positions.ts @@ -0,0 +1,31 @@ +// Where a card sits in its queue, for a writer placing cards ahead of the others +// (`journal-unsent-send-hold.ts`). The queue sends in position order. + +import type Database from '../../sqlite/sync-database' + +/** A card to move, named by its id or, for one handed off, by the submission that consumed it. */ +export type QueuedMessagePositionMove = + | { messageId: string; position: number } + | { consumedAs: string; position: number } + +/** Returns how many rows moved. */ +export function moveQueuedMessages( + db: Database.Database, + sessionId: string, + moves: readonly QueuedMessagePositionMove[] +): number { + let moved = 0 + for (const move of moves) { + const [column, key] = + 'messageId' in move ? ['message_id', move.messageId] : ['consumed_as', move.consumedAs] + moved += Number( + db + .prepare( + `UPDATE queued_messages SET position = ? + WHERE session_id = ? AND ${column} = ? AND position <> ?` + ) + .run(move.position, sessionId, key, move.position).changes ?? 0 + ) + } + return moved +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-retention.ts b/src/main/native-chat/agent-session-journal/queued-message-retention.ts index cf18a1e5006..46717268ad1 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-retention.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-retention.ts @@ -7,7 +7,7 @@ import { listQueuedMessages } from './queued-message-table' /** What the loaded journal says about a dispatched draft's consumed submission. */ export type QueuedMessageSubmissionVerdict = - /** Still owed an answer — a crash leftover the delivery loop will reject; keep the row. */ + /** Still owed an answer — a crash leftover the next open settles; keep the row. */ | 'pending' /** `accepted` or `unknown`: terminal and not refused. */ | 'terminal-not-refused' diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts index 8d2e9ec0acc..029bdb74e6f 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -58,6 +58,7 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, + origin: submission?.origin, now: input.now }) settled += changed ? 1 : 0 @@ -81,7 +82,8 @@ export function settleOwedQueuedMessages( * The live hook, before `row` applies: an echo proving a waiting draft's first * send was delivered withdraws it; a row that NEWLY settles a dispatched * draft's current submission to `rejected` settles the draft — a refusal - * returns it, a withdrawal (a Stop, a restart) sends it back to waiting. + * returns it, a withdrawal (a Stop, a restart) sends it back to waiting + * (`rejectedDraftSettlement`). * Decided by the same function the reducer folds rows through, so a row the * journal's settlement rules ignore never alters a draft. Returns how many * drafts changed. @@ -113,7 +115,8 @@ export function settleQueuedMessagesForRow( if (row.kind !== 'dispatch' || row.state !== 'rejected') { return changed } - if (!journalDispatchRowNewlyRejects(input.state.submissions.get(row.clientMessageId), row)) { + const submission = input.state.submissions.get(row.clientMessageId) + if (!journalDispatchRowNewlyRejects(submission, row)) { return changed } const settled = settleRejectedQueuedMessage(db, { @@ -121,6 +124,7 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, + origin: submission?.origin, now: input.now }) return changed + (settled ? 1 : 0) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index a9c0ef7c70c..264d17b572a 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -13,6 +13,7 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' @@ -90,7 +91,12 @@ async function queueDraft(journal: AgentSessionJournal, messageId: string, text async function consumeDraft( journal: AgentSessionJournal, messageId: string, - options: { as?: string; expect?: 'waiting' | 'returned'; settledByOp?: string | null } = {} + options: { + as?: string + expect?: 'waiting' | 'returned' + settledByOp?: string | null + origin?: 'client' | 'host' + } = {} ) { const draft = journal.queuedMessages.get(messageId) await journal.appendSubmission( @@ -99,7 +105,8 @@ async function consumeDraft( payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, body: draft?.body ?? message('queued text'), fence: 0, - handoverRecorded: true + handoverRecorded: true, + ...(options.origin ? { origin: options.origin } : {}) }, { messageId, @@ -400,25 +407,32 @@ describe('returned transition (D1/N4)', () => { expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') }) - it("a restart between consume and handover sends the draft back to waiting under the restart's pause", async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => - journal.wroteBeforeOpen(submission.acceptedSequence) - ) - // No stored hold: the restart's pause derives from the row's host instance. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - hostInstance: 'proc-1', - consumedAs: null, - returnedReason: null - }) - }) + it.each([ + { by: 'the queue', origin: 'host' as const, holdReason: null }, + { by: 'the person', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + ])( + 'a restart between $by’s consume and handover sends the draft back to waiting', + async ({ origin, holdReason }) => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1', { origin }) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + // The queue's own hand-off waits under the restart's pause, derived from the row's host + // instance; a Send the person asked for waits for them, kept. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason, + hostInstance: 'proc-1', + consumedAs: null, + returnedReason: null + }) + } + ) it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { const journal = await open() @@ -569,6 +583,46 @@ describe('open-time repair and retention', () => { } }) + // The repair reaches the live hook's answer from the stored rejection and who asked for it. + it.each([ + { + origin: 'client' as const, + cause: 'hostRestarted' as const, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }, + { origin: 'host' as const, cause: 'hostRestarted' as const, holdReason: null }, + { + origin: 'client' as const, + cause: 'chatClosed' as const, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }, + { origin: 'host' as const, cause: 'chatClosed' as const, holdReason: null } + ])( + 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, holdReason $holdReason', + async ({ origin, cause, holdReason }) => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1', { origin }) + await journal.rejectQueuedSubmissions( + 0, + agentSessionFailureWords(agentSessionFailureFact(cause), { surface: 'rejection' }) + ) + await journal.close() + // The hook "was skipped": the draft is back to dispatched behind the stored rejection. + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason, + consumedAs: null + }) + } + ) + it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { let journal = await open() await queueDraft(journal, 'draft-1') diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts new file mode 100644 index 00000000000..f5a10c42073 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -0,0 +1,98 @@ +// Reads a `queued_messages` row back (`queued-message-table.ts`): a row this build cannot +// re-materialize is dropped, never shown as an empty message. + +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + readAgentSessionMessageSource, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' +import { readStoredRejectionFact } from './journal-dispatch-reducer' +import type { QueuedMessageRow } from './queued-message-table' + +/** A `queued_messages` row as this file's SELECTs return it; null when it cannot be read back. */ +export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | null { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. + const record = row as { + session_id: string + message_id: string + position: number + body_json: string + fingerprint: string + created_at: number + host_instance: string + state: string + hold_reason: string | null + returned_reason: string | null + returned_rejection: string | null + settled_at: number | null + settled_by_op: string | null + consumed_as: string | null + carried_from: string | null + queued_epoch: string | null + queued_sequence: number | null + source_json: string | null + } + let body: AgentJournalMessageItem + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. + body = JSON.parse(record.body_json) as AgentJournalMessageItem + } catch { + // Our own writer stringified it; an unreadable body is corruption, and a + // row we cannot re-materialize must not masquerade as an empty message. + return null + } + const state = record.state + if ( + state !== 'waiting' && + state !== 'dispatched' && + state !== 'returned' && + state !== 'withdrawn' + ) { + return null + } + return { + sessionId: record.session_id, + messageId: record.message_id, + position: record.position, + body, + fingerprint: record.fingerprint, + createdAt: record.created_at, + hostInstance: record.host_instance, + state, + holdReason: record.hold_reason, + returnedReason: record.returned_reason, + returnedRejection: storedRejection(record.returned_rejection), + settledAt: record.settled_at, + settledByOp: record.settled_by_op, + consumedAs: record.consumed_as, + carriedFrom: record.carried_from, + queuedAt: + record.queued_epoch !== null && typeof record.queued_sequence === 'number' + ? { epoch: record.queued_epoch, sequence: record.queued_sequence } + : null, + source: storedSource(record.source_json) + } +} + +function storedSource(json: string | null): AgentSessionMessageSource { + let stored: unknown = null + try { + stored = json === null ? null : JSON.parse(json) + } catch { + // An unreadable value is read as no value; the source reader decides what that means. + } + return readAgentSessionMessageSource(stored) +} + +function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { + if (json === null) { + return null + } + try { + return readStoredRejectionFact(JSON.parse(json)) ?? null + } catch { + // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. + return null + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1a82925f1f9..d68341888a6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -14,20 +14,26 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { - readAgentSessionMessageSource, + QUEUED_MESSAGE_PAUSED_KEPT, + type QUEUED_MESSAGE_PAUSED_SEND_FAILED +} from '../../../shared/agent-session-queued-message-wire' +import { serializeAgentSessionMessageSource, type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -import { readStoredRejectionFact } from './journal-dispatch-reducer' +import { readStoredQueuedMessageRow } from './queued-message-stored-row' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' -/** Why ONE waiting draft is held from auto-sending: its conversion failed. - * Stored on the row, so it survives handle eviction and restart; a wire marker - * (it publishes as `pausedReason`). A Stop or a restart pauses the whole queue - * instead. A reader treats an unknown stored value as a plain hold. */ -export type QueuedMessageHoldReason = 'send_failed' +/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`), or it + * is a send the host accepted and kept across a restart or a close (`kept`). Stored on the row, so + * it survives handle eviction and restart; a wire marker (it publishes as `pausedReason`). A Stop + * or a restart pauses the whole queue instead. A reader treats an unknown stored value as a plain + * hold. */ +export type QueuedMessageHoldReason = + | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED + | typeof QUEUED_MESSAGE_PAUSED_KEPT /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and * what the published list shows. Pending/unknown/accepted deliveries and @@ -83,16 +89,19 @@ export function insertQueuedMessage( queuedAt: AgentJournalCursor source: AgentSessionMessageSource now: number + /** Absent: after every other card. */ + position?: number + holdReason?: QueuedMessageHoldReason } ): QueuedMessageRow { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. const highest = db .prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?') .get(input.sessionId) as { p?: number } | undefined - const position = Number(highest?.p ?? 0) + 1 + const position = input.position ?? Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -101,6 +110,7 @@ export function insertQueuedMessage( input.fingerprint, input.now, input.hostInstance, + input.holdReason ?? null, input.carriedFrom ?? null, input.queuedAt.epoch, input.queuedAt.sequence, @@ -115,7 +125,7 @@ export function insertQueuedMessage( createdAt: input.now, hostInstance: input.hostInstance, state: 'waiting', - holdReason: null, + holdReason: input.holdReason ?? null, returnedReason: null, returnedRejection: null, settledAt: null, @@ -131,7 +141,7 @@ export function listQueuedMessages(db: Database.Database, sessionId: string): Qu return db .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`) .all(sessionId) - .flatMap((row) => toStoredRow(row) ?? []) + .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) } export function getQueuedMessage( @@ -142,7 +152,7 @@ export function getQueuedMessage( const row = db .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`) .get(sessionId, messageId) - return row === undefined ? null : toStoredRow(row) + return row === undefined ? null : readStoredQueuedMessageRow(row) } /** @@ -230,8 +240,8 @@ export function withdrawQueuedMessages( * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused * again still settles while a late duplicate of an earlier refusal matches - * nothing. A draft back to waiting keeps its position and carries no refusal; - * its spent submissions stay findable by their `queuedMessageId` link. + * nothing. A draft back to waiting keeps its position and carries no refusal, held as `kept` + * when the settlement says so; its spent submissions stay findable by their `queuedMessageId` link. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -240,20 +250,26 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined + /** Who asked for the rejected hand-off (`AgentJournalSubmission.origin`). */ + origin: 'client' | 'host' | undefined now: number } ): boolean { - const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection }) + const settlement = rejectedDraftSettlement(input) const changed = settlement.state === 'waiting' ? db .prepare( `UPDATE queued_messages - SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, + SET state = 'waiting', hold_reason = ?, consumed_as = NULL, returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` ) - .run(input.sessionId, input.consumedRef) + .run( + settlement.kept ? QUEUED_MESSAGE_PAUSED_KEPT : null, + input.sessionId, + input.consumedRef + ) : db .prepare( `UPDATE queued_messages @@ -282,91 +298,5 @@ export function queuedMessagesSettledByOp( WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC` ) .all(sessionId, settledByOp) - .flatMap((row) => toStoredRow(row) ?? []) -} - -function toStoredRow(row: unknown): QueuedMessageRow | null { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. - const record = row as { - session_id: string - message_id: string - position: number - body_json: string - fingerprint: string - created_at: number - host_instance: string - state: string - hold_reason: string | null - returned_reason: string | null - returned_rejection: string | null - settled_at: number | null - settled_by_op: string | null - consumed_as: string | null - carried_from: string | null - queued_epoch: string | null - queued_sequence: number | null - source_json: string | null - } - let body: AgentJournalMessageItem - try { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. - body = JSON.parse(record.body_json) as AgentJournalMessageItem - } catch { - // Our own writer stringified it; an unreadable body is corruption, and a - // row we cannot re-materialize must not masquerade as an empty message. - return null - } - const state = record.state - if ( - state !== 'waiting' && - state !== 'dispatched' && - state !== 'returned' && - state !== 'withdrawn' - ) { - return null - } - return { - sessionId: record.session_id, - messageId: record.message_id, - position: record.position, - body, - fingerprint: record.fingerprint, - createdAt: record.created_at, - hostInstance: record.host_instance, - state, - holdReason: record.hold_reason, - returnedReason: record.returned_reason, - returnedRejection: storedRejection(record.returned_rejection), - settledAt: record.settled_at, - settledByOp: record.settled_by_op, - consumedAs: record.consumed_as, - carriedFrom: record.carried_from, - queuedAt: - record.queued_epoch !== null && typeof record.queued_sequence === 'number' - ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null, - source: storedSource(record.source_json) - } -} - -function storedSource(json: string | null): AgentSessionMessageSource { - let stored: unknown = null - try { - stored = json === null ? null : JSON.parse(json) - } catch { - // An unreadable value is read as no value; the source reader decides what that means. - } - return readAgentSessionMessageSource(stored) -} - -function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { - if (json === null) { - return null - } - try { - return readStoredRejectionFact(JSON.parse(json)) ?? null - } catch { - // The refusal stays readable from `returned_reason`; a bad fact must not lose the card. - return null - } + .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 99900a2a893..7f7eb05fe1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -47,6 +47,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' const CALLER = { callerKey: 'client-1' } @@ -648,18 +649,33 @@ describe('a start whose failure the delivery loop settles before the exit is pub }) describe('Stop withdraws what is queued', () => { - it('withdraws a crash leftover ahead of any delivery step (W17a)', async () => { + it('never meets a crash leftover: the open it runs settles it first (W17a)', async () => { await writeAsEarlierProcess(async (journal, fence) => { + await journal.appendSubmission({ + ...earlierSubmission('person', 'p', true), + origin: 'client', + source: USER_MESSAGE_SOURCE, + fence + }) await journal.appendSubmission({ ...earlierSubmission('leftover', 'l', true), fence }) }) - // Stop's own open wakes the delivery loop, whose first step queues behind this Stop. expect(await stop()).toMatchObject({ ok: true }) + // A person's message is kept as a held card, which no Stop withdraws; the rest is rejected. + const hostRestarted = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + expect(await submission('person')).toMatchObject({ + dispatchState: 'rejected', + ...hostRestarted + }) expect(await submission('leftover')).toMatchObject({ dispatchState: 'rejected', - reason: DISPATCH_REJECTED_CANCELLED + ...hostRestarted }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual(['person']) expect(acquire).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 30c4fbbf933..4033c70de1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -14,7 +14,7 @@ import { createJournalOpenReadRefusals } from '../agent-session-journal/journal- import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import { releaseLeaseOfEndedStructuredAgentSessionChild } from './structured-agent-session-child-close' import { - abandonQueuedStructuredAgentSessionMessages, + holdClosedStructuredAgentSessionSends, closeStructuredAgentSessionConversationUnderSerialize, stopStructuredAgentSessionAgentUnderSerialize, type StructuredAgentSessionCloseCause, @@ -153,14 +153,14 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is - * still queued will not be sent. */ + * still queued will not be sent; a person's message stays as a held card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => serialize(sessionId, async () => { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Abandoned before the stop, so no start delivers it. - await abandonQueuedStructuredAgentSessionMessages(deps(), sessionId, session.journal) + // Settled before the stop, so no start delivers it. + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index dc3d4fc42ae..9c556916a39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -5,12 +5,14 @@ // process handed over and left unanswered as in doubt, and settles what it left running — the // crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A -// row an earlier process accepted and never handed over is the delivery loop's, which the open -// wakes. Nothing here starts a provider child. +// row an earlier process accepted and never handed over (it quit or crashed first) is settled here +// too, before any reader, command or child sees it: a person's message is kept as a held card, the +// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -21,6 +23,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -93,8 +96,7 @@ export async function openStructuredAgentSessionConversationJournal( deferPerSessionImport: options.deferPerSessionImport }) try { - // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over - // one is only doubt, which provider history decides under a won lease. + // A handed-over row found here is only doubt, which provider history decides under a won lease. await journal.markPendingSubmissionsUnknown(fence) } catch (error) { deps.logger.warn('marking pending sends unknown on open failed', { @@ -103,6 +105,22 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + try { + // Before a Stop this open serves can withdraw one: a Stop never withdraws a card. + await holdUnsentSends(journal, { + fence, + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'hostRestarted' } + }) + } catch (error) { + // The row stays queued. The delivery loop's first step tries again before it hands anything + // over; if that fails too, the loop fails and rejects every queued send. + deps.logger.warn('settling sends an earlier process left queued failed on open', { + scope: 'open-leftover-sends', + sessionId, + error + }) + } // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index e3aaa965edc..3d5d3705043 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -8,7 +8,9 @@ // record to decide, so there is no loop state to disagree with them. Each step is its own serialized task. That is what lets a Stop // that arrives while a start holds the queue withdraw the queued messages before the handover that // would have written them. Stop and the conversation's close are the only other writers of a -// queued message: a child's exit only ends the child, and this loop reads why. +// queued message: a child's exit only ends the child, and this loop reads why. A message an +// earlier host process left queued is never handed over: the open, or this loop's first step, +// settles it first (`journal-unsent-send-hold.ts`). import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -17,10 +19,9 @@ import { agentSessionFailureFact, type SubmissionRejectionFact } from '../../../shared/agent-session-failure' -import { - agentSessionFailureWords, - type AgentSessionFailureWordsContext -} from '../../../shared/agent-session-failure-words' +import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -58,8 +59,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = { ) => Promise /** The fence the conversation's own writes carry; see `structuredAgentSessionConversationFence`. */ conversationFence: (sessionId: string) => number - /** Rejects queued messages as a completed close of the chat does; false when that failed. */ - abandonQueued: ( + /** Settles queued messages as a completed close of the chat does; false when that failed. */ + holdClosed: ( sessionId: string, which: (submission: AgentJournalSubmission) => boolean ) => Promise @@ -142,7 +143,7 @@ export class StructuredAgentSessionDeliveryLoop { await this.deps .serialize(sessionId, () => this.fail(sessionId, { startKey: null, cause })) .catch((failure: unknown) => { - // Rows left queued are rejected by the next open, or by the next loop an accept wakes. + // Rows left queued go to the next loop an accept wakes, or the next open settles them. this.running.delete(sessionId) this.deps.logger.warn('recording a failed delivery failed', { scope: 'delivery-loop-fail', @@ -159,12 +160,14 @@ export class StructuredAgentSessionDeliveryLoop { if (!session || this.disposed) { return this.stop(sessionId) } - await session.journal.rejectQueuedSubmissions( - this.deps.conversationFence(sessionId), - agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { surface: 'rejection' }), - // A handle closes only with nothing queued, so one an earlier handle wrote is a leftover. - (submission) => session.journal.wroteBeforeOpen(submission.acceptedSequence) - ) + // The open already did, unless its write failed: a row an earlier handle wrote is never handed + // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then + // fails, which rejects every queued send, this process's own too. + await holdUnsentSends(session.journal, { + fence: this.deps.conversationFence(sessionId), + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'hostRestarted' } + }) if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) @@ -298,7 +301,7 @@ export class StructuredAgentSessionDeliveryLoop { return true } const { epoch } = session.journal.cursor() - return this.deps.abandonQueued( + return this.deps.holdClosed( sessionId, (submission) => ended.endedAt.epoch === epoch && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 80460bb72fa..d1bb1cfa794 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -3,7 +3,7 @@ // with a message queued has a delivery loop — and the open is where a loop for leftovers wakes. import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' +import { holdClosedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openStructuredAgentSessionConversation, @@ -65,10 +65,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { ensureProviderChild: input.ensureProviderChild, conversationFence: (sessionId) => structuredAgentSessionConversationFence(deps.store, sessionId), - abandonQueued: async (sessionId, which) => { + holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? abandonQueuedStructuredAgentSessionMessages(deps, sessionId, session.journal, which) + ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, which) : true }, failureTextContext: (sessionId) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 5e8d7e2097f..fda7bc9c27e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -10,8 +10,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' import { snapshotBeforeStructuredAgentSessionStop, StructuredAgentSessionEvictionError @@ -25,6 +24,7 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -60,33 +60,32 @@ type ConversationCloseDeps = Pick & { store: Pick } -/** A conversation's handle closes with nothing queued: what is still queued when the chat closes, - * or the app quits, will not be handed over. Best effort: the next open's delivery loop rejects a - * leftover itself. `which` narrows it to the messages a close that did not complete closed. - * Resolves false when the rejection failed; the failure is reported, never thrown. */ -export async function abandonQueuedStructuredAgentSessionMessages( +/** What is still queued when the chat closes will not be handed over: a person's message is kept + * as a held card, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the + * next open settles what it left. `which` narrows it to the messages a close that did not complete + * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and + * never thrown. */ +export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], which?: (submission: AgentJournalSubmission) => boolean ): Promise { - return journal - .rejectQueuedSubmissions( - structuredAgentSessionConversationFence(deps.store, sessionId), - agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }), - which - ) - .then( - () => true, - (error: unknown) => { - deps.logger.warn('rejecting queued messages of a closed chat failed', { - scope: 'queued-abandon', - sessionId, - error - }) - return false - } - ) + return holdUnsentSends(journal, { + fence: structuredAgentSessionConversationFence(deps.store, sessionId), + hostInstance: structuredAgentSessionHostInstance(), + hold: { cause: 'chatClosed', ...(which ? { which } : {}) } + }).then( + () => true, + (error: unknown) => { + deps.logger.warn('settling queued messages of a closed chat failed', { + scope: 'queued-abandon', + sessionId, + error + }) + return false + } + ) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index ab632de9dda..ddfd7c25ff4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -37,7 +37,7 @@ import { setOptionPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { cancelStructuredAgentSessionPrompt } from './structured-agent-session-prompt-cancel' import { mutateWithChatStop } from './structured-agent-session-chat-stop' export type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' @@ -61,9 +61,10 @@ export function sendStructuredAgentSessionTurn( * Orchestration mail, a restart continuation and `agent.launch`'s host-sent * prompt never set it. */ userSend?: true - /** Host-local, never on the wire: who a host-side `queue-if-active` send queues for, recorded - * on its card. A client's send is always its person's (`userSend`). */ - source?: AgentMessageSource + /** Host-local, never on the wire: who a host-side send is from. A queued one records it on + * its card, a direct one its kind on the submission. A client's send is always its person's + * (`userSend`). */ + source?: AgentSessionMessageSource beforeRun?: () => void }, arrival?: Parameters[2] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index bbae1b4db16..fc93e4a43d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -1,9 +1,9 @@ // Host teardown, made failure-complete. // // Every phase runs whatever an earlier one threw: `flushAllEventSinks` throws BY DESIGN when a -// sink barrier fails, and the attach drain can reject too. Each conversation is then retired — -// what it still queues settled, its admitted writes drained — before the runtime closes the one -// journal connection, last. +// sink barrier fails, and the attach drain can reject too. Each conversation is then retired — its +// admitted writes drained, what it still queues left for its next open to keep — before the +// runtime closes the one journal connection, last. import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' @@ -11,7 +11,6 @@ import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider import { SNAPSHOT_DRAIN_TIMEOUT_MS } from './structured-agent-session-eviction' import type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' import { - abandonQueuedStructuredAgentSessionMessages, evictOwnedStructuredAgentSessions, type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' @@ -109,8 +108,6 @@ async function tearDownStructuredAgentSessionHost(input: { sessions: Map retainSessionIds?: ReadonlySet acknowledgeSessionRelease?: (sessionId: string) => void - /** Quit closes every conversation, so it settles what they still queue as a close does. */ - abandonQueued?: (sessionId: string, session: StructuredAgentSessionHostSession) => Promise }): Promise { const failures: unknown[] = [] for (const phase of input.phases) { @@ -124,18 +121,18 @@ async function tearDownStructuredAgentSessionHost(input: { const entries = [...input.sessions.entries()].filter( ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) - // `allSettled`, so one failed settlement cannot skip the others. + // Quit settles nothing still queued: delivery and the queue's drain are already disposed, so + // nothing hands it over now, and the next open settles it as it would after a crash. `allSettled`, so one failed close + // cannot skip the others. const closed = await Promise.allSettled( - entries.map(async ([sessionId, session]) => { - await input.abandonQueued?.(sessionId, session) + entries.map(async ([, session]) => { await session.journal.close() }) ) closed.forEach((result, index) => { const sessionId = entries[index]?.[0] if (result.status === 'fulfilled') { - // Only a settled conversation drops out. One whose queued sends could not be settled stays - // indexed, so a later stop retries that settlement. + // Only a closed conversation drops out; one whose close failed stays indexed for a retry. if (sessionId !== undefined) { input.sessions.delete(sessionId) input.acknowledgeSessionRelease?.(sessionId) @@ -180,10 +177,6 @@ export async function flushStructuredAgentSessionHost( sessions: context.sessions, retainSessionIds, acknowledgeSessionRelease: (sessionId) => - context.deps.adapter.acknowledgeSessionRelease?.(sessionId), - // Quit's is best effort: a failure is reported, and the next open rejects the leftover. - abandonQueued: async (sessionId, session) => { - await abandonQueuedStructuredAgentSessionMessages(context.deps, sessionId, session.journal) - } + context.deps.adapter.acknowledgeSessionRelease?.(sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 0b6a1fd89cf..b82ece68435 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -263,8 +263,10 @@ export class StructuredAgentSessionHost { // Trigger inlined rather than imported: `AgentSessionResumeTrigger` in shared is the canonical // type, and this file has no line budget left for the import. - /** Quit: no exit or recovery settled after this starts a child or hands a message over. */ - stopDelivery = (): void => this.conversationDelivery.dispose() + /** Quit: no exit or recovery settled after this starts a child or hands a message over, and the + * queue hands no card over. */ + stopDelivery = (): void => + [this.conversationDelivery, this.queued.drain].forEach((d) => d.dispose()) async flushAllStreamedEvents(options?: { trigger?: 'quit' | 'update' }): Promise { this.stopDelivery() 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 6a2a6268ae9..6190d74b51b 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 @@ -8,6 +8,10 @@ // one that wrote nothing. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + USER_MESSAGE_SOURCE, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { @@ -77,6 +81,8 @@ export function sendPlan(params: { retryUnknown?: true delivery?: 'queue-if-active' userSend?: true + /** Who a host-side send is from; a person's send (`userSend`) is always the user. */ + source?: AgentSessionMessageSource beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -96,8 +102,10 @@ export function sendPlan(params: { run: (ctx) => { // Asked at acceptance: a send accepted after this one is queued behind it. params.beforeRun?.() + const source = params.userSend ? USER_MESSAGE_SOURCE : params.source return performSend(ctx, { origin: params.userSend ? 'client' : 'host', + ...(source ? { source } : {}), clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body @@ -105,8 +113,13 @@ export function sendPlan(params: { }, replay: (ctx, outcome) => { // A send this host queued answers from its draft, then its hand-off; a - // withdrawn draft replays as spent — never as missing-submission doubt. - const queued = queuedSendAnswer(ctx.journal, clientMessageId) + // withdrawn draft replays as spent — never as missing-submission doubt. Only a send that + // asked to be queued may get that answer: a direct send the host kept as a card answers + // from its own submission, which a client that never sent `delivery` can read. + const queued = + params.delivery === 'queue-if-active' + ? queuedSendAnswer(ctx.journal, clientMessageId) + : null if (queued) { return queued } @@ -163,6 +176,7 @@ export function conversationCommandPlan(params: { clientMessageId, // Only a client asks through the command RPC: the person's own turn. origin: 'client', + source: USER_MESSAGE_SOURCE, payloadFingerprint: params.envelope.payloadFingerprint, body: structuredAgentSessionCompactBody() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 6f0c544f377..2551cd33237 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -153,9 +153,9 @@ function sendParams(text: string) { } } -async function accept(text: string): Promise { +async function accept(text: string, options: { person?: true } = {}): Promise { const params = sendParams(text) - const sent = await host.send(CALLER, params) + const sent = await host.send(CALLER, { ...params, ...(options.person ? { userSend: true } : {}) }) expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) return params.envelope.clientOperationId } @@ -611,7 +611,13 @@ describe('another child indexed while the loop waits on the one it started', () }) }) +// A quit settles nothing still queued: the next launch's open keeps a person's message as a held +// card and rejects the rest, as a crash's would. describe('a quit with a message still queued', () => { + const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + /** Read by the next launch, through the same open any reader takes. */ async function afterRelaunch(id: string): Promise { startHost() @@ -619,17 +625,30 @@ describe('a quit with a message still queued', () => { return await submission(id) } - it('settles a message no child ever had the way a chat close does (R2)', async () => { + async function keptCards(): Promise { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + return page.ok ? (page.page.queuedMessages ?? []).map((card) => card.messageId) : [] + } + + it('keeps a person’s message no child ever had as a held card for the next launch (R2)', async () => { // Quit has begun — its first step stops the delivery loops — when this message is accepted. host['conversationDelivery'].loop.dispose() - const id = await accept('hello') + const id = await accept('hello', { person: true }) expect(conversation()?.child).toBeNull() await host.flushAllStreamedEvents() - expect(await afterRelaunch(id)).toMatchObject({ - dispatchState: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) - }) + expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(await keptCards()).toEqual([id]) + expect(dispatch).not.toHaveBeenCalled() + }) + + it("rejects a message from Orca itself at the next launch, as a crash's would", async () => { + host['conversationDelivery'].loop.dispose() + const id = await accept('from orca') + await host.flushAllStreamedEvents() + + expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(await keptCards()).toEqual([]) }) it('waits for the start already in flight and stops the child it produced (R2)', async () => { @@ -644,7 +663,7 @@ describe('a quit with a message still queued', () => { await starting.promise return resolveRecovery(sessionId) }) - const id = await accept('hello') + const id = await accept('hello', { person: true }) await eventually(() => expect(recovering).toHaveBeenCalled()) const quit = host.flushAllStreamedEvents() @@ -654,10 +673,8 @@ describe('a quit with a message still queued', () => { expect(closeSession).toHaveBeenCalledWith(SESSION) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) expect(dispatch).not.toHaveBeenCalled() - expect(await afterRelaunch(id)).toMatchObject({ - dispatchState: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) - }) + expect(await afterRelaunch(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(await keptCards()).toEqual([id]) }) }) @@ -765,16 +782,38 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) + it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { + const start = deferred() + adapterExtras = { + awaitStarted: vi.fn(() => start.promise), + closeSession: vi.fn(async () => true) + } + await restartHost() + acquire.mockImplementationOnce(spawnStartingChild) + const first = await accept('first', { person: true }) + await eventually(() => expect(adapterExtras.awaitStarted).toHaveBeenCalledTimes(1)) + await closeStopOnly() + const starts = acquire.mock.calls.length + start.resolve() + await settleLoop() + + expect(await submission(first)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) + expect(acquire).toHaveBeenCalledTimes(starts) + expect(dispatch).not.toHaveBeenCalled() + }) + it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { const { first, starts } = await closedWhileStarting(() => { const journal = conversation()!.journal - const reject = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation(async (...args) => { - if (args[1].rejection.kind === 'chatClosed') { - vi.mocked(journal.rejectQueuedSubmissions).mockImplementation(reject) + const resolve = journal.resolveDispatch.bind(journal) + vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (...args) => { + if (args[0].state === 'rejected' && args[0].rejection?.kind === 'chatClosed') { + vi.mocked(journal.resolveDispatch).mockImplementation(resolve) throw new Error('disk full') } - return reject(...args) + return resolve(...args) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts new file mode 100644 index 00000000000..864ad55f13c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig-provider.test-fixture.ts @@ -0,0 +1,120 @@ +// The scripted provider behind the queued-message rig: the adapter double the host drives, and +// the events it writes back as the provider would. + +import { vi, type Mock } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' + +export type QueuedRigProviderOptions = { + /** A child started for a chat whose chain already names a thread resumes it, so a chat whose + * child closed or died can start another. */ + restartable?: true + /** Every child stays starting. */ + starting?: true + /** The provider's Stop ends its child, as Claude's does. */ + stopEndsSession?: true +} + +export function createQueuedRigProvider( + store: Pick, + options: QueuedRigProviderOptions +) { + // Admitted: the message is written and unanswered, so the session owes work + // until the test settles it. + const dispatch: Mock = vi.fn(async () => ({ + state: 'admitted' as const + })) + const awaitStarted: Mock> = vi.fn( + async () => undefined + ) + // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. + const compact: Mock> = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: null + })) + const cancelTurn: Mock = vi.fn(async () => ({ + cancelled: true + })) + const closeSession: Mock> = vi.fn( + async () => true + ) + let events: StructuredAgentSessionEventSink | undefined + + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ identity, fence, spawnToken, events: sink }) => { + events = sink + const resumes = + options.restartable === true && + (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + acquisitionGeneration: 'generation-1', + ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), + link: { + linkId: `link-${fence}`, + handle: codexProviderHandle(THREAD), + origin: resumes ? ('resumed' as const) : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch, + awaitStarted, + closeSession, + releaseAcquisition: vi.fn(async () => true), + compact, + cancelTurn, + ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } + + /** What the provider's translator writes when a /compact's turn ends, as a success. */ + function finishCompact(): void { + const { command } = compact.mock.calls.at(-1)![0] + events!.appendLifecycleBatch!( + `turn-completed:${command.clientMessageId}`, + [ + { + kind: 'item', + identity: command.identity, + body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ], + { lifecycle: true } + ) + } + + /** The event sink the provider writes through. */ + function providerEvents(): StructuredAgentSessionEventSink { + if (!events) { + throw new Error('no provider bound') + } + return events + } + + return { + adapter, + dispatch, + awaitStarted, + compact, + cancelTurn, + closeSession, + finishCompact, + providerEvents + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 668209d1513..bf2d1a5575e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -1,10 +1,10 @@ -// One real-host rig for the mid-turn queue suites: store, journal, adapter -// mocks, and the send/stop/draft helpers every suite shares. +// One real-host rig for the mid-turn queue suites: store, journal, the scripted provider +// (`...-rig-provider.test-fixture.ts`), and the send/stop/draft helpers every suite shares. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { expect, vi, type Mock } from 'vitest' +import { expect, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -12,9 +12,6 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' import { @@ -28,7 +25,10 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + createQueuedRigProvider, + type QueuedRigProviderOptions +} from './structured-agent-session-queued-message-rig-provider.test-fixture' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -40,80 +40,23 @@ export function eventually(assertion: () => void | Promise): Promise export type QueuedMessageTestRig = Awaited> -/** `restartable`: a child started for a chat whose chain already names a thread resumes it, so a - * chat whose child closed or died can start another. `starting`: every child stays starting. */ export async function createQueuedMessageTestRig( - options: { - restartable?: true - starting?: true + options: QueuedRigProviderOptions & { /** Lets a test sweep idle chats on its own `tick`. */ idleSweep?: { idleMs: number; intervalMs: number } - /** The provider's Stop ends its child, as Claude's does. */ - stopEndsSession?: true } = {} ) { const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-')) resetHostTestOperationIds() - // Admitted: the message is written and unanswered, so the session owes work - // until the test settles it. - const dispatch: Mock = vi.fn(async () => ({ - state: 'admitted' as const - })) - const awaitStarted: Mock> = vi.fn( - async () => undefined - ) - // The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it. - const compact: Mock> = vi.fn(async () => ({ - state: 'accepted' as const, - providerIdentity: null - })) - const cancelTurn: Mock = vi.fn(async () => ({ - cancelled: true - })) - const closeSession: Mock> = vi.fn( - async () => true - ) - let events: StructuredAgentSessionEventSink | undefined const store = await openTestAgentSessionRecordStore(root) + const provider = createQueuedRigProvider(store, options) + const { dispatch, awaitStarted, compact, cancelTurn, closeSession } = provider const makeHost = () => new StructuredAgentSessionHost({ agents: claudeAndCodexDeclared(), logger: createStructuredAgentSessionLogger(), store, - adapter: { - acquire: async ({ identity, fence, spawnToken, events: sink }) => { - events = sink - const resumes = - options.restartable === true && - (store.getRecord(identity.sessionId)?.providerHandleChain.length ?? 0) > 0 - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken - }, - acquisitionGeneration: 'generation-1', - ...(options.starting ? { providerChildPhase: 'starting' as const } : {}), - link: { - linkId: `link-${fence}`, - handle: codexProviderHandle(THREAD), - origin: resumes ? ('resumed' as const) : ('created' as const), - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch, - awaitStarted, - closeSession, - releaseAcquisition: vi.fn(async () => true), - compact, - cancelTurn, - ...(options.stopEndsSession ? { stopEndsSession: () => true } : {}), - answerPrompt: vi.fn(async () => undefined), - setOption: vi.fn(async () => undefined) - }, + adapter: provider.adapter, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', @@ -253,31 +196,6 @@ export async function createQueuedMessageTestRig( }) } - /** What the provider's translator writes when a /compact's turn ends, as a success. */ - function finishCompact(): void { - const { command } = compact.mock.calls.at(-1)![0] - events!.appendLifecycleBatch!( - `turn-completed:${command.clientMessageId}`, - [ - { - kind: 'item', - identity: command.identity, - body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ], - { lifecycle: true } - ) - } - - /** The event sink the provider writes through. */ - function providerEvents(): StructuredAgentSessionEventSink { - if (!events) { - throw new Error('no provider bound') - } - return events - } - /** A host-process restart, as the queue sees it: the conversation closes, and * opens afresh under a new instance id while its rows survive. The close is an eviction, whose * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that @@ -293,6 +211,12 @@ export async function createQueuedMessageTestRig( host = makeHost() } + /** The app quits — the host's own teardown runs — and a new host opens the same state. */ + async function quitRestartHostProcess(): Promise { + await host.flushAllStreamedEvents() + crashRestartHostProcess() + } + /** The queue's published pause: null when it sends on its own. */ async function queuePause(sessionId = SESSION): Promise { const page = await host.history({ sessionId, direction: 'tail' }) @@ -324,8 +248,8 @@ export async function createQueuedMessageTestRig( closeSession, awaitStarted, compact, - finishCompact, - providerEvents, + finishCompact: provider.finishCompact, + providerEvents: provider.providerEvents, envelope, send, stop, @@ -340,6 +264,7 @@ export async function createQueuedMessageTestRig( settleRejected, restartHostProcess, crashRestartHostProcess, + quitRestartHostProcess, queuePause, resume, dispose diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index d4c7c470f12..3d451a72e0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { USER_MESSAGE_SOURCE, - type AgentMessageSource + type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, @@ -198,7 +198,7 @@ export async function maybeQueueStructuredAgentSessionSend( /** A person's send at a chat surface; it outranks any `source`. */ userSend?: true /** Who a host-side send is from. */ - source?: AgentMessageSource + source?: AgentSessionMessageSource } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -271,9 +271,17 @@ export type QueuedMessageDrainDeps = { */ export class StructuredAgentSessionQueuedMessageDrain { private readonly scheduled = new Set() + private disposed = false constructor(private readonly deps: QueuedMessageDrainDeps) {} + /** Quit, with delivery: a hand-off made now could only be settled by the next process, so a + * quit leaves the cards exactly as a crash does. Read by the step at its start, and again + * right before it appends, since quit can land while it awaits. */ + dispose(): void { + this.disposed = true + } + schedule(sessionId: string): void { const journal = this.deps.sessions.get(sessionId)?.journal if (!journal) { @@ -319,7 +327,7 @@ export class StructuredAgentSessionQueuedMessageDrain { private async step(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) - if (!session) { + if (this.disposed || !session) { return } const journal = session.journal @@ -342,7 +350,7 @@ export class StructuredAgentSessionQueuedMessageDrain { // Live facts only, through the one gate; the backlog is never a gate, so a // lone draft drains. Whatever clears a hold publishes or commits, which // re-derives this step. - if (structuredQueueHold({ journal, record, fence }) !== null) { + if (this.disposed || structuredQueueHold({ journal, record, fence }) !== null) { return } // Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 8554bb08154..0eb300fdef8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -7,12 +7,13 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' -import type { - AgentSessionMutationEnvelope, - AgentSessionMutationResult, - AgentSessionQueuedMessageDeleteResult, - AgentSessionQueuedMessagesResumeResult, - AgentSessionSendResult +import { + QUEUED_MESSAGE_PAUSED_KEPT, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionQueuedMessageDeleteResult, + type AgentSessionQueuedMessagesResumeResult, + type AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' @@ -118,7 +119,11 @@ export async function carryQueuedMessagesToClearReplacement( fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - source: row.source + source: row.source, + // A kept send stays held there too: no later message may release it. + ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT + ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + : {}) }) } await withdrawQueuedMessagesForOperation(ctx.journal, { @@ -314,7 +319,7 @@ export function deleteQueuedStructuredAgentMessage( /** Resume: ends the queue's pause — a Stop's, or a restart's — so the cards send * again, oldest first, as the session goes idle. A no-op when nothing is paused, - * and a per-card `send_failed` hold stays for its own Send. */ + * and a per-card hold (`send_failed`, `kept`) stays for its own Send. */ export function resumeStructuredAgentQueue( context: StructuredAgentSessionMutationContext, caller: StructuredAgentSessionCaller, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index 1fa46616da1..50f3b9ef270 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -4,8 +4,10 @@ // re-sending it. The two ride together: a client never sees one without the other. import { + QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, + type AgentSessionQueuedMessagePausedReason, type AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -18,7 +20,8 @@ export type QueuePublication = { } /** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion); a Stop or a restart pauses the queue, published once beside it. */ + * conversion, or a send the host kept); a Stop or a restart pauses the queue, + * published once beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -33,9 +36,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess state: row.state, ...(held ? { paused: true as const } : {}), // The stored reason is a typed marker; an unknown one reads as a plain hold. - ...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED - ? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED } - : {}), + ...(held && isPublishedPausedReason(row.holdReason) ? { pausedReason: row.holdReason } : {}), ...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}), ...(row.state === 'returned' && row.returnedRejection ? { returnedRejection: row.returnedRejection } @@ -45,6 +46,12 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess return published } +function isPublishedPausedReason( + reason: string | null +): reason is AgentSessionQueuedMessagePausedReason { + return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED || reason === QUEUED_MESSAGE_PAUSED_KEPT +} + type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } /** Reference-stable per journal handle: an unchanged list is never diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 9abdc713f0e..d27d521c40e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -4,7 +4,7 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -17,7 +17,7 @@ export async function runQueueableStructuredAgentSessionSend( body: AgentJournalMessageItem delivery?: 'queue-if-active' userSend?: true - source?: AgentMessageSource + source?: AgentSessionMessageSource }, immediate: () => Promise> ): Promise> { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts index 4dff44ff2ab..49d3d849ec3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation-wait.test.ts @@ -4,7 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' +import { + STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER, + structuredAgentSessionRestartResumeSurfaces +} from './structured-agent-session-restart-resume-wiring' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' const SESSION = 'session-1' @@ -111,3 +114,31 @@ describe('a restart batch holds a chat until its continuation is handed over', ( await expect(handedOver).resolves.toBeUndefined() }) }) + +// No source: Orca's own instruction is no person's, so a restart or a close rejects it, never keeps +// it as a card; the next restart offers the continuation again. +it('sends the continuation with no source, as Orca’s and no person’s', async () => { + const send = vi.fn(async () => { + throw new Error('refused') + }) + const surfaces = structuredAgentSessionRestartResumeSurfaces( + { + revealSession: async () => ({ readable: true }), + send, + waitForSendSettlement: async () => undefined + }, + () => 0 + ) + const envelope = { + sessionId: SESSION, + clientOperationId: MESSAGE, + expectedRuntimeFence: 2, + payloadFingerprint: 'fingerprint' + } + const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } + await expect(surfaces.send({ envelope, body })).rejects.toThrow('refused') + expect(send.mock.calls[0]).toEqual([ + { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, + { envelope, body } + ]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 4019582e1fd..23d2e589776 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -132,6 +132,9 @@ it('replays the same logical continuation through the durable send ledger', asyn if (!sent) { throw new Error('the continuation was not sent') } + // No person's: re-derived by its own records after another restart, so never kept as a card. + expect(sent).not.toHaveProperty('source') + expect((await host.journalSnapshot(SESSION)).submissions[0]).not.toHaveProperty('source') const replay = await host.send( { callerKey: STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER }, { envelope: sent.envelope, body: sent.body } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index a1bb644d84e..d53d06f35a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -4,6 +4,7 @@ // every chat had its own file, and opens no file it does not restore. import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -41,6 +42,7 @@ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-sess import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ readOnlyOpens: new Array(), @@ -114,7 +116,7 @@ function legacyDirFor(sessionId: string): string { } /** What the run before the upgrade left open in a chat, for its restore to settle. */ -type MidWork = 'running tool call' | 'unresolved send' +type MidWork = 'running tool call' | 'unresolved send' | 'never handed over' /** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ async function seedLegacyChat( @@ -134,7 +136,11 @@ async function seedLegacyChat( payloadFingerprint: 'fp-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, fence: 1, - handoverRecorded: true + handoverRecorded: true, + // A person's send that run accepted and quit before handing over. + ...(midWork === 'never handed over' + ? { origin: 'client' as const, source: USER_MESSAGE_SOURCE } + : {}) }) if (midWork === 'running tool call') { await journal.appendItem( @@ -143,6 +149,10 @@ async function seedLegacyChat( { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } + if (midWork === 'never handed over') { + await journal.close() + return writeLegacyChat(sessionId, scratch, journal.epoch) + } await journal.resolveDispatch( midWork === 'unresolved send' ? // Handed over, and never answered. @@ -172,11 +182,16 @@ async function seedLegacyChat( ) } await journal.close() - const rows = readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - sessionId, - journal.epoch - ) + return writeLegacyChat(sessionId, scratch, journal.epoch) +} + +/** Moves the rows a scratch host wrote into the chat's own per-chat file, as an older build kept it. */ +async function writeLegacyChat( + sessionId: string, + scratch: string, + epoch: string +): Promise { + const rows = readTestJournalRows(openTestJournalHostDatabase(scratch).db, sessionId, epoch) const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) await mkdir(dirname(path), { recursive: true }) const db = new Database(path) @@ -329,7 +344,7 @@ describe('startup restore of chats still in their per-chat files', () => { // Restore copies a chat only to write to it itself, settling what the last run left open (a // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. - it.each(['running tool call', 'unresolved send'] as const)( + it.each(['running tool call', 'unresolved send', 'never handed over'] as const)( 'copies during restore only a chat it settles (%s)', async (midWork) => { const rows = await seedLegacyChat('chat-mid-work', 1, midWork) @@ -350,6 +365,31 @@ describe('startup restore of chats still in their per-chat files', () => { } ) + // A send the last run never handed over is kept as a card in the same database the copy wrote, + // after the copy: the card and the rejected send both land behind the chat's own rows. + it('keeps a send the last run never handed over as a card, after the copy', async () => { + const rows = await seedLegacyChat('chat-kept', 0, 'never handed over') + + const { sessions } = await restore(['chat-kept']) + + const journal = sessions.get('chat-kept')!.journal + await journal.whenImported() + const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch) + expect(copied.slice(0, rows.length)).toEqual(rows) + expect(copied).toHaveLength(rows.length + 1) + expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ + kind: 'dispatch', + clientMessageId: 'client-chat-kept', + state: 'rejected' + }) + const cards = hostDb() + .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') + .all('chat-kept') + expect(cards).toEqual([ + { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } + ]) + }) + it('lets other work run while it reads a large per-chat file', async () => { // Past one batch of the file's rows. const rows = await seedLegacyChat('chat-a', 520) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 2d5a9a133e4..8956e2d84fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -15,6 +15,7 @@ import type { AgentJournalMessageItem, AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' import { refuse, type AgentSessionRefusalReason, @@ -132,6 +133,8 @@ export async function performSend( body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' + /** Who it is from; the submission keeps the kind only. */ + source?: AgentSessionMessageSource } ): Promise> { const existing = ctx.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts new file mode 100644 index 00000000000..0219207a6cc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -0,0 +1,652 @@ +// A message the host accepted while the agent was starting, then Orca quit or crashed, or the chat +// closed, before handing it over: it is kept as a held card at the head of the queue, never sent on +// its own, and released only by the person's own Send, Edit or Delete on it. Against the real host, +// store and journal. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +/** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ +const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [{ party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null } }], + orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } +} + +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) +const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' +}) +const KEPT = { state: 'waiting', paused: true } + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rig.dispose() +}) + +/** The exact request a client sends, so a test can send the same one again. */ +function sendRequest(text: string, delivery?: 'queue-if-active') { + const body = hostTestMessage(text) + const fields = { body, ...(delivery ? { delivery } : {}) } + return { + envelope: rig.envelope(fields, 'agentSession.send', hostTestOperationId()), + body, + ...(delivery ? { delivery } : {}), + userSend: true as const + } +} + +/** Accepted while the agent is starting, and never handed over: its start never finishes. */ +async function acceptWhileStarting( + request: Parameters[1] +): Promise { + // No child, so this send must start one. + await rig.host.close(SESSION, 'evict') + const startsBefore = rig.awaitStarted.mock.calls.length + rig.awaitStarted.mockImplementationOnce(() => new Promise(() => undefined)) + expect(await rig.host.send(QUEUED_RIG_CALLER, request)).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'pending', handoverRecorded: true } } + }) + await eventually(() => expect(rig.awaitStarted.mock.calls.length).toBeGreaterThan(startsBefore)) + return request.envelope.clientOperationId +} + +function journal(): AgentSessionJournal { + const open = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!open) { + throw new Error('the conversation is not open') + } + return open +} + +function dispatchedTexts(): string[] { + return rig.dispatch.mock.calls.map(([input]) => + input.body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + ) +} + +describe('a message accepted while the agent starts, then Orca stops', () => { + it.each([ + { how: 'quit', restart: () => rig.quitRestartHostProcess() }, + { how: 'crash', restart: async () => rig.crashRestartHostProcess() } + ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { + const id = await acceptWhileStarting(sendRequest('hello after restart')) + await restart() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // Held on its own: no queue-wide pause, so no Resume to press. + expect(await rig.queuePause()).toBeNull() + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: { kind: 'user' } + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + hostInstance: structuredAgentSessionHostInstance(), + body: hostTestMessage('hello after restart'), + queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } + }) + // Nothing is sent on its own, however many times the chat reopens. + await rig.host.close(SESSION, 'evict') + rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + }) + + it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { + const id = await acceptWhileStarting(sendRequest('kept through quit')) + await rig.quitRestartHostProcess() + // Read beside the host, without opening the chat through it: the row the quit left. + const leftover = await peekSubmission(id) + expect(leftover).toMatchObject({ dispatchState: 'pending', handoverRecorded: true }) + expect(leftover?.handedOverAt).toBeUndefined() + }) + + it('keeps several in the order they were accepted, ahead of the cards already queued', async () => { + const first = await acceptWhileStarting(sendRequest('first')) + const second = sendRequest('second') + expect(await rig.host.send(QUEUED_RIG_CALLER, second)).toMatchObject({ ok: true }) + await rig.crashRestartHostProcess() + + expect(await rig.drafts()).toEqual([ + { messageId: first, ...KEPT }, + { messageId: second.envelope.clientOperationId, ...KEPT } + ]) + }) +}) + +describe('only an action on the card releases a kept card', () => { + it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { + const id = await acceptWhileStarting(sendRequest('the kept words')) + await rig.quitRestartHostProcess() + + // The person types the same words again, as one who cannot see cards would. + const retyped = rig.send('the kept words') + await retyped.result + await eventually(async () => + expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(retyped.id, 'retyped') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(dispatchedTexts()).toEqual(['the kept words']) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + + await rig.resume() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(id)).toBeUndefined() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + + expect(await rig.sendNow(id)).toMatchObject({ ok: true }) + const handoff = await rig.handoff(id) + expect(handoff?.clientMessageId).not.toBe(id) + expect(handoff).toMatchObject({ origin: 'client' }) + await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) + expect(await rig.drafts()).toEqual([]) + }) + + it('Send now sends it at once, and Delete removes it', async () => { + const sent = await acceptWhileStarting(sendRequest('send me now')) + const deleted = sendRequest('delete me') + await rig.host.send(QUEUED_RIG_CALLER, deleted) + await rig.quitRestartHostProcess() + + expect(await rig.deleteQueued(deleted.envelope.clientOperationId)).toMatchObject({ + ok: true, + value: { deleted: true } + }) + expect(await rig.sendNow(sent)).toMatchObject({ + ok: true, + value: { submission: { queuedMessageId: sent, origin: 'client' } } + }) + await eventually(() => expect(dispatchedTexts()).toEqual(['send me now'])) + expect(await rig.drafts()).toEqual([]) + }) + + // Edit is the card's text in the composer, the card's Delete, then a new send. Neither leaves + // anything of the original send, even beside the sending desktop's own copy of it. + it.each(['Edit', 'Delete'] as const)('%s leaves no trace of the kept send', async (action) => { + const request = sendRequest('the kept words') + const id = await acceptWhileStarting(request) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(await rig.deleteQueued(id)).toMatchObject({ ok: true, value: { deleted: true } }) + const edited = action === 'Edit' ? rig.send('the edited words') : null + if (edited) { + await edited.result + await eventually(async () => + expect((await rig.submission(edited.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(edited.id, 'edited') + } + + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + const lingering = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: id, + sessionId: SESSION, + text: 'the kept words', + attachments: [], + queuedAt: 1 + }), + state: 'rejected' as const + } + const shown = projectStructuredAgentSessionMessages( + page.page.items, + [lingering], + page.page.submissions, + // The desktop's transcript, which draws a rejected send in place unless it was kept. + { rejectedInPlace: true } + ).map((message) => ({ + text: message.blocks.map((block) => ('text' in block ? block.text : '')).join(''), + unsent: message.unsent ?? false + })) + expect(shown).toEqual(action === 'Edit' ? [{ text: 'the edited words', unsent: false }] : []) + expect(await rig.drafts()).toEqual([]) + }) + + it('a Stop on the reopened chat leaves it kept: the open settles it before the Stop runs', async () => { + const id = await acceptWhileStarting(sendRequest('survives a stop')) + await rig.crashRestartHostProcess() + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + }) + + // A Send now cut short by a quit hands the card back kept, so a retyped copy still goes alone. + // After a second restart the kept card is another process's, yet it pauses nothing else. + it('a later restart leaves the cards queued after it unpaused', async () => { + const id = await acceptWhileStarting(sendRequest('kept twice over')) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + rig.crashRestartHostProcess() + const working = await rig.workingSend() + const later = rig.send('queued after the restart', 'queue-if-active') + expect(await later.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + + expect(await rig.queuePause()).toBeNull() + await rig.settleAccepted(working, 'working') + await eventually(() => + expect(dispatchedTexts()).toEqual(['work on this', 'queued after the restart']) + ) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + }) + + it('stays kept when its own Send is cut short by another quit', async () => { + const id = await acceptWhileStarting(sendRequest('the kept words')) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // A /compact runs, so the Send now waits behind it, and Orca quits before it is handed over. + const fields = { command: 'compact' as const } + expect( + await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + expect(await rig.sendNow(id)).toMatchObject({ ok: true }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect((await rig.handoff(id))?.handedOverAt).toBeUndefined() + await rig.quitRestartHostProcess() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + const retyped = rig.send('the kept words') + await retyped.result + await eventually(async () => + expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(retyped.id, 'retyped') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(dispatchedTexts()).toEqual(['the kept words']) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + }) +}) + +// The queue stops with delivery at quit, so a quit makes no hand-off only the next process could +// settle: the queued cards come back as a crash leaves them, under the restart's pause. +describe('cards queued behind a working turn, then Orca stops', () => { + it.each(['quit', 'crash'] as const)( + 'after a %s they wait under the restart pause, not kept, with no hand-off made', + async (how) => { + await rig.workingSend() + const first = rig.send('queued behind work', 'queue-if-active') + expect(await first.result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + const second = rig.send('second queued behind work', 'queue-if-active') + expect(await second.result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + if (how === 'quit') { + await rig.quitRestartHostProcess() + } else { + rig.crashRestartHostProcess() + } + + expect(await rig.drafts()).toEqual([ + { messageId: first.id, state: 'waiting' }, + { messageId: second.id, state: 'waiting' } + ]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.handoff(first.id)).toBeUndefined() + expect(journal().queuedMessages.get(first.id)?.holdReason).toBeNull() + } + ) +}) + +describe('the queue at a quit', () => { + /** A /compact the provider took; its end, written later by `finishCompact`, wakes the drain. */ + async function compactRunning(): Promise { + const fields = { command: 'compact' as const } + expect( + await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + ).toMatchObject({ ok: true }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + } + + // Quit's first step is `stopDelivery`, before teardown drains recovery; the flush repeats it. + it.each(['stopDelivery', 'flushAllStreamedEvents'] as const)( + 'a drain step already running when quit begins (%s) makes no hand-off', + async (quitStep) => { + await compactRunning() + const queued = rig.send('queued behind the compact', 'queue-if-active') + expect(await queued.result).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + const host = rig.host + const { queuedMessages } = journal() + const settleOwed = queuedMessages.settleOwed.bind(queuedMessages) + let release = (): void => undefined + const held = new Promise((resolve) => (release = resolve)) + let reached = (): void => undefined + const inStep = new Promise((resolve) => (reached = resolve)) + let blocked = false + // Holds the drain step at its one await, past its first dispose check, until quit has begun. + // Only the drain step heals owed bookkeeping, so no caller check is needed (nor a stack read, + // which runtimes format differently). + const owed = vi.spyOn(queuedMessages, 'settlementOwed').mockImplementation(() => !blocked) + const healing = vi.spyOn(queuedMessages, 'settleOwed').mockImplementation(async () => { + if (!blocked) { + blocked = true + reached() + await held + } + return settleOwed() + }) + rig.finishCompact() + await inStep + if (quitStep === 'stopDelivery') { + host.stopDelivery() + release() + await healing.mock.results[0]?.value + // The step's append check runs on the turn after its heal resolves. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(await rig.handoff(queued.id)).toBeUndefined() + } else { + const quitting = host.flushAllStreamedEvents() + release() + await quitting + } + owed.mockRestore() + healing.mockRestore() + rig.crashRestartHostProcess() + + expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) + + // The card the person pushed ahead waits for their own Send; Resume sends the rest. + it('Send now on an ordinary card, cut short by a quit, returns it kept while Resume sends the rest', async () => { + await compactRunning() + const first = rig.send('first queued', 'queue-if-active') + await first.result + const pushed = rig.send('pushed ahead', 'queue-if-active') + await pushed.result + expect(await rig.sendNow(pushed.id)).toMatchObject({ ok: true }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect((await rig.handoff(pushed.id))?.handedOverAt).toBeUndefined() + await rig.quitRestartHostProcess() + + expect(await rig.drafts()).toEqual([ + { messageId: pushed.id, ...KEPT }, + { messageId: first.id, state: 'waiting' } + ]) + expect(await rig.queuePause()).toEqual({ reason: 'restarted' }) + await rig.resume() + await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.drafts()).toEqual([{ messageId: pushed.id, ...KEPT }]) + }) +}) + +// The same rule as a restart: tab close, worktree teardown and an orchestration stop all close the +// chat, and the chat can be reopened from its history. +describe('a message accepted while the agent starts, then the chat closes', () => { + it.each(['user-close', 'evict'] as const)( + 'after a %s it is a held card the reopened chat shows, rejected as closed', + async (cause) => { + const id = await acceptWhileStarting(sendRequest('kept at close')) + await rig.host.close(SESSION, cause) + rig.crashRestartHostProcess() + + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + // The desktop drops its own copy at tab close; the card is what shows the words. + const texts = projectStructuredAgentSessionMessages( + page.page.items, + [], + page.page.submissions, + { rejectedInPlace: true } + ) + .flatMap((shown) => shown.blocks.map((block) => ('text' in block ? block.text : ''))) + .join('|') + expect(texts).not.toContain('kept at close') + expect(page.page.queuedMessages?.[0]).toMatchObject({ + body: hostTestMessage('kept at close'), + pausedReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) +}) + +// The desktop's outbox keeps an unconfirmed send and sends it again, under the same id, once the +// app is back. That replay meets the card the open made from the same send. +describe('the same send arriving again after the restart', () => { + it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { + const request = sendRequest('sent again by the outbox') + const id = await acceptWhileStarting(request) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + const submissionsBefore = (await rig.host.journalSnapshot(SESSION)).submissions.length + + const again = await rig.host.send(QUEUED_RIG_CALLER, request) + + // Never the queued arm, which only a client that sent `delivery` can read; the card it sees + // under the same id is what tells it the host holds the message. + expect(again).toMatchObject({ + ok: true, + replayed: true, + value: { clientMessageId: id, submission: { dispatchState: 'rejected', ...HOST_RESTARTED } } + }) + expect(again.ok && 'queued' in again.value).toBe(false) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(submissionsBefore) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + + // Retry on such a client sends the words under a new id: delivered once, the card still held. + const retried = rig.send('sent again by the outbox') + await retried.result + await eventually(async () => + expect((await rig.submission(retried.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(retried.id, 'retried') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(dispatchedTexts()).toEqual(['sent again by the outbox']) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + }) + + it('a client that asked to queue is told the host holds it', async () => { + const request = sendRequest('queued-capable resend', 'queue-if-active') + const id = await acceptWhileStarting(request) + await rig.crashRestartHostProcess() + + expect(await rig.host.send(QUEUED_RIG_CALLER, request)).toMatchObject({ + ok: true, + value: { clientMessageId: id, queued: { messageId: id, state: 'waiting' } } + }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + }) +}) + +describe('what is not kept', () => { + // Mail names its sender (an agent); a dispatch preamble names none. Neither is a person's. + it.each([ + { by: 'mail', source: MAIL_SOURCE, recorded: { kind: 'agent' } }, + { by: 'dispatch', source: undefined, recorded: undefined } + ])('an orchestration $by send is rejected, as before', async ({ by, source, recorded }) => { + const { userSend: _person, ...sent } = sendRequest(by) + await acceptWhileStarting({ ...sent, ...(source ? { source } : {}) }) + await rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([]) + const submission = await rig.submission(sent.envelope.clientOperationId) + expect(submission).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + expect(submission?.source).toEqual(recorded) + }) + + it('a send whose card could not be written is rejected as before, and nothing stays queued', async () => { + const id = await acceptWhileStarting(sendRequest('card write fails')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'holdInTransaction').mockImplementation(() => { + throw new Error('disk full') + }) + rig.crashRestartHostProcess() + + await eventually(async () => + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED + }) + ) + expect(await rig.drafts()).toEqual([]) + // No card holds it, so its rejection names none. + expect(await rig.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') + expect(warned).toHaveBeenCalledWith( + '[journal-hold] keeping an unsent send failed:', + expect.objectContaining({ clientMessageId: id, cause: 'hostRestarted' }) + ) + }) + + it('the delivery step keeps one the open could not settle, and hands nothing over', async () => { + const id = await acceptWhileStarting(sendRequest('open write failed')) + const resolve = AgentSessionJournal.prototype.resolveDispatch + // The open's keep and its plain-rejection fallback both fail. + let failures = 2 + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(AgentSessionJournal.prototype, 'resolveDispatch').mockImplementation(function ( + this: AgentSessionJournal, + ...args: Parameters + ) { + if (failures > 0 && args[0].clientMessageId === id) { + failures -= 1 + return Promise.reject(new Error('disk busy')) + } + return resolve.apply(this, args) + }) + await rig.quitRestartHostProcess() + // The open's write failed; a new send wakes the delivery loop, whose first step settles it. + const next = rig.send('wakes the loop') + await next.result + await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }])) + await eventually(() => expect(dispatchedTexts()).toEqual(['wakes the loop'])) + expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) + }) +}) + +describe('/clear carries a kept card still held', () => { + it('a turn in the new conversation never releases it', async () => { + const id = await acceptWhileStarting(sendRequest('carried through clear')) + await rig.quitRestartHostProcess() + expect(await rig.drafts()).toHaveLength(1) + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(QUEUED_RIG_CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!replacementId) { + throw new Error('expected a replacement session') + } + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) + const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal + expect(carried?.queuedMessages.list()[0]?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + + const body = hostTestMessage('first turn after the clear') + const turn = await rig.host.send(QUEUED_RIG_CALLER, { + envelope: { + sessionId: replacementId, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: replacementId, + fields: { body } + }) + }, + body, + userSend: true + }) + const turnId = turn.ok ? turn.value.clientMessageId : '' + await eventually(async () => + expect( + (await rig.host.journalSnapshot(replacementId)).submissions.find( + (entry) => entry.clientMessageId === turnId + )?.handedOverAt + ).toBeDefined() + ) + await rig.host.settleLateDispatch({ + sessionId: replacementId, + clientMessageId: turnId, + providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } + }) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) + expect(dispatchedTexts()).toEqual(['first turn after the clear']) + }) +}) + +/** The submission as the quit left it, read from a journal opened beside the host's. */ +async function peekSubmission(id: string) { + const record = rig.store.getRecord(SESSION)! + const params = attachParamsForRecord(record, { + clientOperationId: 'peek', + expectedRuntimeFence: record.lease.runtimeFence + }) + const peeked = await openAgentSessionJournal({ + identity: journalIdentityFor(record, params), + database: openTestJournalHostDatabase(rig.root) + }) + try { + return peeked.submission(id) + } finally { + await peeked.close() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 217360673fc..9f42431e773 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -182,7 +182,7 @@ export function structuredAgentSessionWorkingAtStop(input: { return null } const snapshot = session.journal.snapshot() - // A queued message reached no agent, so it is no work to resume: quit rejects it as never sent. + // A queued message reached no agent, so it is no work to resume: the next open settles it. const handedOver = snapshot.submissions.filter( (submission) => !isQueuedAgentJournalSubmission(submission) ) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index 22fa2b41528..d3bb13bafa3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -3,10 +3,8 @@ // was open before the command saw, or the journal a client would load. import { beforeEach, expect, it, vi, type Mock } from 'vitest' -import { - AgentJournalSubmissionSchema, - isAdmissibleAgentJournalItemBody -} from '../../../shared/agent-session-journal-schemas' +import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, diff --git a/src/main/runtime/orchestration/send-agent-turn-host.test.ts b/src/main/runtime/orchestration/send-agent-turn-host.test.ts index 0f92d95cee8..9ba15ea1765 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -125,8 +125,10 @@ describe('sendAgentTurn through the real host', () => { }) /** Settles a handed-over send as the provider taking it; the turn's wait ends on that. */ - async function sendTurnAccepted(delivery: AgentTurnDelivery) { - const operationId = hostTestOperationId() + async function sendTurnAccepted( + delivery: AgentTurnDelivery, + operationId = hostTestOperationId() + ) { const outcome = sendTurn(delivery, operationId) await eventually(async () => expect((await rig.submission(operationId))?.handedOverAt).toBeDefined() @@ -143,6 +145,16 @@ describe('sendAgentTurn through the real host', () => { expect(await rig.drafts()).toEqual([]) }) + // An idle chat sends the mail at once: its submission says it is an agent's, without the senders + // the card keeps host-only, so a restart or a close rejects it rather than keep it as a card. + it('records an idle chat’s mail as an agent’s, by kind only', async () => { + const operationId = hostTestOperationId() + await sendTurnAccepted('queue', operationId) + const submission = await rig.submission(operationId) + expect(submission?.source).toEqual({ kind: 'agent' }) + expect(JSON.stringify(submission)).not.toContain('term_peer') + }) + it('has a `now` send join the running turn, never the queue', async () => { await rig.workingSend() await expect(sendTurnAccepted('now')).resolves.toMatchObject({ diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts index 1b591051936..55717cca96b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -52,6 +52,8 @@ describe('committing a launch prompt', () => { expect(messageId).toBe(params.envelope.clientOperationId) expect(params.envelope).toMatchObject({ sessionId: 'sess-1', expectedRuntimeFence: 4 }) expect(params.body).toEqual(structuredAgentSessionSendBody('do the thing', [])) + // A restart keeps a launch's first prompt like a person's message, so the send says it is one. + expect(params.source).toEqual({ kind: 'user' }) // The host recomputes and compares this, so a launch send must fingerprint like a client send. expect(params.envelope.payloadFingerprint).toBe( structuredAgentSessionPayloadFingerprint({ diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 00c779fffe0..e18f557163d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -24,6 +24,7 @@ import { createStructuredAgentSessionOperationId } from '../../../../shared/stru import { randomUUID } from 'node:crypto' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import { USER_MESSAGE_SOURCE } from '../../../../shared/agent-session-message-source' /** * The committed transcript row's id, or `null` when nothing was committed. @@ -54,10 +55,11 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { queuedAt: Date.now() }) try { - const result = await args.host.send( - args.caller, - structuredAgentSessionSendMutation(entry, args.fence) - ) + const result = await args.host.send(args.caller, { + ...structuredAgentSessionSendMutation(entry, args.fence), + // A person's first prompt, sent for them: kept as a card if a restart or a close comes first. + source: USER_MESSAGE_SOURCE + }) return result.ok ? result.value.clientMessageId : null } catch (error) { // Settlement can fail after the journal append. Re-read the authoritative row before asking diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index b501acc74c6..02797d54d6c 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -273,6 +273,15 @@ describe('structured worker dispatch preamble', () => { const send = (host: PreambleHost) => sendStructuredWorkerPreamble({ host, sessionId: 's1', dispatchId: 'd1', preamble: 'spec' }) + // No source and no person: a restart or a close rejects it, and orchestration re-derives it. + it('sends the preamble as no person’s', async () => { + const host = hostWithSubmission({ dispatchState: 'accepted', reason: null }) + const sent = vi.spyOn(host, 'send') + await send(host) + expect(sent.mock.calls[0]?.[1]).not.toHaveProperty('source') + expect(sent.mock.calls[0]?.[1]).not.toHaveProperty('userSend') + }) + it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts index a4b68ccbcd3..259ee2dbe14 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -1,6 +1,6 @@ // The queued-message actions: Send-now and Delete on one card, and Resume on a -// paused queue. All gated on agent-session.queued-messages.v1; an older host -// lacks the methods entirely. +// paused queue. Not gated on agent-session.queued-messages.v1: a host without it +// still publishes the cards it kept unsent. A host older than the queue lacks them. import { defineMethod } from '../core' import { 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 d86b5bc394b..06bc89d8c16 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,8 +10,6 @@ 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() @@ -44,7 +42,7 @@ function transcript(items: AgentJournalRenderItem[]) { return ( Promise.resolve('exhausted' as const) function Transcript({ items }: { items: AgentJournalRenderItem[] }) { - const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY, EMPTY) + const messages = useStructuredAgentSessionMessages(items, EMPTY, EMPTY) const session: NativeChatLiveSession = { messages, status: 'working', 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 3578cec8f0f..28affc5512d 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 @@ -26,8 +26,6 @@ import { structuredAgentSessionDeliveryNotices } from './structured-agent-sessio 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() @@ -139,7 +137,7 @@ function list(phase: Phase, scoped: boolean, outbox: StructuredAgentSessionOutbo return ( void onDelete: () => void onEdit: () => void - onTurnOffQueueing: () => void + /** Absent when the host does not queue sends, so there is nothing to turn off. */ + onTurnOffQueueing?: () => void }): React.JSX.Element { const caption = queuedMessageCardCaption(card) const returned = card.state === 'returned' @@ -201,12 +211,14 @@ export function NativeChatQueuedMessageCard({ {translate('components.native-chat.queuedMessages.editMessage', 'Edit message')} - - {translate( - 'components.native-chat.queuedMessages.turnOffQueueing', - 'Turn off queueing' - )} - + {onTurnOffQueueing ? ( + + {translate( + 'components.native-chat.queuedMessages.turnOffQueueing', + 'Turn off queueing' + )} + + ) : null} diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 41fd4852a20..84d6dc4480a 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -46,7 +46,8 @@ function card(overrides: Partial & { messageId: string }): Qu function controller( cards: QueuedMessageCard[], - pause: { reason: string } | null = null + pause: { reason: string } | null = null, + queueCapable = true ): StructuredAgentSessionQueuedMessagesController & { steer: ReturnType remove: ReturnType @@ -55,6 +56,7 @@ function controller( } { return { cards, + queueCapable, pause, resume: vi.fn(async () => {}), resuming: false, @@ -440,6 +442,23 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) }) + // A message the host kept after a restart or a close is not a mid-turn steer: on an idle chat + // its action is plainly Send, and its caption says it was never sent. + it('a kept card says it was not sent yet and offers Send, with no Resume over it', async () => { + renderList( + controller([card({ messageId: 'held', hold: 'paused', pausedReason: 'kept' })], { + reason: 'restarted' + }) + ) + const [row] = screen.getAllByRole('listitem') + expect(row?.textContent).toContain('Not sent yet — press Send to send it.') + expect(row?.textContent).not.toContain('kept') + fireEvent.focus(screen.getByRole('button', { name: 'Send' })) + expect((await screen.findAllByText('Send this message now')).length).toBeGreaterThan(0) + expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() + }) + it('an absent or unknown pause marker reads as a plain pause, never raw', () => { renderList( controller([ @@ -470,4 +489,32 @@ describe('NativeChatQueuedMessageList', () => { fireEvent.click(await screen.findByRole('menuitem', { name: 'Turn off queueing' })) expect(mocks.updateSettings).toHaveBeenCalledWith({ nativeChatQueueFollowUps: false }) }) + + // A kept message shows as a card even where the host does not queue sends; there the setting + // and the chord would do nothing, so neither is offered. + it.each([ + { queueCapable: true, offered: true }, + { queueCapable: false, offered: false } + ])( + 'offers Turn off queueing and the send chord only when the host queues sends ($queueCapable)', + async ({ queueCapable, offered }) => { + const owner = controller( + [card({ messageId: 'kept', hold: 'paused', pausedReason: 'kept' })], + null, + queueCapable + ) + renderList(owner) + fireEvent.focus(screen.getByRole('button', { name: 'Send' })) + const hint = await screen.findAllByText('Send this message now') + expect(hint[0]!.parentElement!.children).toHaveLength(offered ? 2 : 1) + fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' })) + expect(await screen.findByRole('menuitem', { name: 'Edit message' })).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: 'Turn off queueing' }) !== null).toBe(offered) + // Send, Delete and Edit work either way. + fireEvent.click(screen.getByRole('menuitem', { name: 'Edit message' })) + expect(owner.edit).toHaveBeenCalledWith('kept') + fireEvent.click(screen.getByRole('button', { name: 'Send' })) + expect(owner.steer).toHaveBeenCalledWith('kept') + } + ) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 9c47a8c375e..b0960a4410b 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -23,6 +23,10 @@ export function NativeChatQueuedMessageList({ const queueRef = useRef(null) const { cards } = controller const newest = cards.at(-1) + // Only a host that queues sends has queueing to turn off; a kept card shows without it. + const turnOffQueueing = controller.queueCapable + ? () => void updateSettings({ nativeChatQueueFollowUps: false }) + : undefined // A pause over cards Resume would not send (returned, held on their own, or behind a returned // one) offers nothing to press. const pause = cards.some((card) => card.hold === 'queue-paused') ? controller.pause : null @@ -60,11 +64,11 @@ export function NativeChatQueuedMessageList({ refocusAfter(controller.steer(card.messageId))} onDelete={() => refocusAfter(controller.remove(card.messageId))} onEdit={() => refocusAfter(controller.edit(card.messageId))} - onTurnOffQueueing={() => void updateSettings({ nativeChatQueueFollowUps: false })} + onTurnOffQueueing={turnOffQueueing} /> ))} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 8f43b043c81..7c8d6cf5d22 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -129,7 +129,6 @@ export function NativeChatStructuredSession( submissions: controller.submissions, journalItems: controller.journalItems, failedHere: controller.failedHere, - queuedMessageIds: controller.queuedMessageIds, retry: controller.retry, agentName: agentLabel }) 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 16c9a30a397..6d4e0b23a78 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 @@ -19,8 +19,6 @@ import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots' import type { NativeChatTurnDiff } from './native-chat-turn-diffs' import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' -const NO_CARDS: readonly string[] = [] - function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) { return { itemId, revision: 1, sequence, observedAt: 1_000 + sequence, body } } @@ -70,7 +68,7 @@ const JOURNAL: AgentJournalRenderItem[] = [ /** 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, NO_CARDS) + projectStructuredAgentSessionMessages(items, [], submissions) ).conversation const messages = omitNativeChatThreadGoalRows(projectNativeChatTaskListFrames(projected)) let turn: string | undefined diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts new file mode 100644 index 00000000000..3e636a0e558 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.kept-card.test.ts @@ -0,0 +1,80 @@ +// A send the host kept as a queued card is the card's from then on: its own row says nothing, +// neither "Sending…" nor not sent, whatever state the desktop's saved copy was left in. + +import { expect, it } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' + +const KEPT_ID = 'client-kept' + +function savedCopy( + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: KEPT_ID, + sessionId: 'session-1', + text: 'the kept words', + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +const restartRejected: AgentJournalSubmission = { + clientMessageId: KEPT_ID, + fence: 2, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true +} + +const kept: AgentJournalSubmission = { ...restartRejected, keptAsQueuedMessageId: KEPT_ID } + +function notices( + copy: StructuredAgentSessionOutboxEntry, + submissions: readonly AgentJournalSubmission[] +) { + return structuredAgentSessionDeliveryNotices( + [copy], + 'Claude', + () => {}, + submissions, + [], + new Set() + ) +} + +// The saved copy's states after a quit: still going out, resent on its own after a lost answer, +// or already marked not sent. +const COPIES = [ + savedCopy(), + savedCopy({ state: 'dispatching' }), + savedCopy({ state: 'unconfirmed', retryAfterUnknownSubmittedAt: null }), + savedCopy({ state: 'rejected', lastFailure: { kind: 'rejected', reason: 'not sent' } }) +] + +it('gives a kept send no notice, so its saved copy never reads "Sending…" or not sent', () => { + for (const copy of COPIES) { + expect([...notices(copy, [kept])]).toEqual([]) + } +}) + +it('still says not sent, never "Sending…", for a send rejected without being kept', () => { + for (const copy of COPIES) { + const notice = notices(copy, [restartRejected]).get(agentJournalSubmissionKey(KEPT_ID)) + expect(notice).toMatchObject({ text: expect.any(String) }) + expect(notice).not.toHaveProperty('sending') + } +}) 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 9054c35b533..3ed26650ed4 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 @@ -597,7 +597,6 @@ describe('the notice on each message that did not go through', () => { [rejected], [], NOT_FAILED_HERE, - [], new Set(), [loadedRow] ) 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 52f3832dffa..ef91f8c1920 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 @@ -164,8 +164,6 @@ export function structuredAgentSessionDeliveryNotices( startFailures: readonly AgentSessionFailureFact[], /** 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[] = [], /** The loaded commands, from `structuredAgentSessionCommandItemIds`: they report their own. */ commandItemIds: ReadonlySet = NO_COMMANDS, /** The loaded rows: a rejected message's outbox copy leaves once its row is here. */ @@ -214,11 +212,7 @@ export function structuredAgentSessionDeliveryNotices( } } // 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 shown = structuredAgentSessionRejectedShownInPlace(submissions, 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.older-host.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.older-host.test.ts index 7cc13a0a4eb..0c5d273bc54 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 @@ -19,7 +19,6 @@ import { 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 { @@ -126,7 +125,7 @@ it("keeps the outbox copy of a rejected message whose row is outside the window, ) expect(kept).toMatchObject([{ clientMessageId: 'm', state: 'rejected' }]) const drawn = (outbox: typeof kept) => - projectStructuredAgentSessionMessages(state.items, outbox, state.submissions, NO_CARDS) + projectStructuredAgentSessionMessages(state.items, outbox, state.submissions) .filter((message) => message.role === 'user') .map(({ id, unsent }) => ({ id, unsent })) expect(drawn(kept)).toEqual([{ id: MESSAGE_ID, unsent: true }]) 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 index 84e10ca18b7..c27204b91f5 100644 --- 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 @@ -25,7 +25,6 @@ import { 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.' @@ -122,7 +121,7 @@ function shown( state.submissions, state.items ) - const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions, NO_CARDS) + const rows = projectStructuredAgentSessionMessages(state.items, kept, state.submissions) .filter((message) => message.role === 'user') .map(({ id, unsent }) => ({ id, unsent })) const notice = structuredAgentSessionDeliveryNotices( 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 4e757705225..4d94622cfa9 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 @@ -22,8 +22,6 @@ 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) { @@ -133,8 +131,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const messages = projectStructuredAgentSessionMessages( items, [], - [SEED, restartRejected('lost', 'fix the parser', 3)], - NO_CARDS + [SEED, restartRejected('lost', 'fix the parser', 3)] ) expect(rows(messages)).toEqual([ @@ -154,8 +151,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const messages = projectStructuredAgentSessionMessages( items, [], - [SEED, restartRejected('waited', 'fix the parser', 3)], - NO_CARDS + [SEED, restartRejected('waited', 'fix the parser', 3)] ) expect( @@ -212,7 +208,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => submission('resent', 'retry me', 4) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('resent'), text: 'retry me', unsent: false } ]) @@ -227,7 +223,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => submission('again', 'continue', 4) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + 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. @@ -244,7 +240,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => restartRejected('b', 'pointer', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }, { id: agentJournalSubmissionKey('b'), text: 'pointer', unsent: true } ]) @@ -264,7 +260,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => withdrawn('stopped', 'again', 5) ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + 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 } @@ -280,9 +276,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const submissions = [SEED, restartRejected('compact', '/compact', 3)] expect( - rows( - projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions, NO_CARDS) - ) + rows(projectStructuredAgentSessionMessages([...SEED_ROWS, compact], [], submissions)) ).toEqual([{ id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false }]) // One rule decides for the rows and the notices. expect( @@ -293,7 +287,6 @@ describe('a message the host accepted and then rejected, on the desktop', () => submissions, [], new Set(), - NO_CARDS, structuredAgentSessionCommandItemIds([...SEED_ROWS, compact]) ).size ).toBe(0) @@ -303,7 +296,7 @@ describe('a message the host accepted and then rejected, on the desktop', () => const items = [...SEED_ROWS, userItem('stopped', 3, 'never mind')] const submissions = [SEED, withdrawn('stopped', 'never mind', 3)] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ { id: agentJournalSubmissionKey('seed'), text: 'seed', unsent: false } ]) expect( @@ -329,7 +322,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], NO_CARDS) + const messages = projectStructuredAgentSessionMessages(SEED_ROWS, [held], [SEED]) expect(rows(messages)).toEqual([seedRow, heldRow]) const notice = structuredAgentSessionDeliveryNotices( [held], @@ -345,17 +338,13 @@ 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], NO_CARDS)) - ).toEqual([seedRow, { ...heldRow, unsent: false }]) + expect(rows(projectStructuredAgentSessionMessages(SEED_ROWS, [dispatching], [SEED]))).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], - NO_CARDS - ) + 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( @@ -378,7 +367,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], NO_CARDS)) + rows(projectStructuredAgentSessionMessages(hostItems, [resend], [SEED, resent])) ).toEqual([ seedRow, { id: agentJournalSubmissionKey('held'), text: 'outbox copy', unsent: true }, @@ -395,8 +384,7 @@ 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], - NO_CARDS + [SEED, resent, recorded] ) ) ).toEqual([ @@ -417,30 +405,47 @@ describe('a rejected message the queue holds', () => { { ...restartRejected('handoff', 'queued text', 3), queuedMessageId: 'card-1' } ] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, NO_CARDS))).toEqual([ - seedRow - ]) + 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', () => { + // A send kept across a restart comes back as a paused card; the send itself records that card. + // The card's Edit and Delete remove it, so nothing but that record may hide the send. + it('is never drawn once kept as a card, with the card there, deleted, or edited and sent', () => { const items = [...SEED_ROWS, userItem('kept', 3, 'kept text')] - const submissions = [SEED, restartRejected('kept', 'kept text', 3)] + const kept = { ...restartRejected('kept', 'kept text', 3), keptAsQueuedMessageId: 'kept' } + const submissions = [SEED, kept] - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, ['kept']))).toEqual([ - seedRow - ]) + // The card is there, or Delete took it: the transcript reads only the send. + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([seedRow]) expect( - structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set(), [ - 'kept' - ]).size + structuredAgentSessionDeliveryNotices([], 'Claude', () => {}, submissions, [], new Set()).size ).toBe(0) - expect(rows(projectStructuredAgentSessionMessages(items, [], submissions, []))).toEqual([ + // Edit put the text in the composer and the person sent it as a new message. + const edited = submission('edited', 'kept text, edited', 5) + expect( + rows( + projectStructuredAgentSessionMessages( + [...items, userItem('edited', 5, 'kept text, edited')], + [], + [...submissions, edited] + ) + ) + ).toEqual([ seedRow, - { id: agentJournalSubmissionKey('kept'), text: 'kept text', unsent: true } + { id: agentJournalSubmissionKey('edited'), text: 'kept text, edited', unsent: false } + ]) + }) + + it('is drawn as not sent when it was rejected without being kept', () => { + const items = [...SEED_ROWS, userItem('lost', 3, 'lost text')] + const submissions = [SEED, restartRejected('lost', 'lost text', 3)] + + expect(rows(projectStructuredAgentSessionMessages(items, [], submissions))).toEqual([ + seedRow, + { id: agentJournalSubmissionKey('lost'), text: 'lost 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 bd99e8f8cbb..1108d468739 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,8 +7,6 @@ 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}`, @@ -44,9 +42,7 @@ describe('structured agent session message projection', () => { attachments: [], queuedAt: 1 }) - expect( - projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected], NO_CARDS) - ).toEqual([ + expect(projectStructuredAgentSessionMessages([refusedItem], [draft], [rejected])).toEqual([ expect.objectContaining({ id: refusedItem.itemId, blocks: [{ type: 'text', text: 'send 0' }], @@ -68,8 +64,7 @@ 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)), - NO_CARDS + Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)) ) expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount) @@ -104,8 +99,8 @@ describe('structured agent session message projection', () => { resolvedAt: null } - const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending], NO_CARDS) - const optimistic = projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS) + const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending]) + const optimistic = projectStructuredAgentSessionMessages([], outbox, []) expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) expect(messages.map((message) => message.id)).toEqual([walItem.itemId]) @@ -123,7 +118,7 @@ describe('structured agent session message projection', () => { }) ] - expect(projectStructuredAgentSessionMessages([], outbox, [], NO_CARDS)).toMatchObject([ + expect(projectStructuredAgentSessionMessages([], outbox, [])).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 f527442c2b1..cb620ed72d8 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,19 +7,17 @@ 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, unless a queued card holds it. */ + * sent, as not sent, unless the queue holds it as a card. */ export function projectStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[], - /** The queue's live cards; required, since a rejected message a card holds must not draw twice. */ - queuedMessageIds: readonly string[] + submissions: readonly AgentJournalSubmission[] ) { return projectMessages( items, outbox, submissions, - { rejectedInPlace: true, queuedMessageIds }, + { rejectedInPlace: true }, projectStructuredQuestionMessages ) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 995d8024769..4dc58abf490 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -159,6 +159,21 @@ describe('queued message cards', () => { ).toBe('awaiting-answer') }) + // A kept card is held on its own, like a failed one: the host sends the cards behind it. + it('a card behind a kept or a send_failed card is not held by it', () => { + const cards = projectQueuedMessageCards( + [ + draft('failed', 1, { paused: true, pausedReason: 'send_failed' }), + draft('after-failed', 2), + draft('kept', 3, { paused: true, pausedReason: 'kept' }), + draft('behind', 4) + ], + [], + IDLE + ) + expect(cards.map((card) => card.hold)).toEqual(['paused', 'turn', 'paused', 'turn']) + }) + it('steers the newest card', () => { const cards = projectQueuedMessageCards([draft('a', 1), draft('b', 2)], [], IDLE) expect(newestSteerableQueuedMessageCard(cards)?.messageId).toBe('b') 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 3b12ed7b3dc..aa774f7fbf3 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,8 +13,6 @@ 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, @@ -45,7 +43,7 @@ function drawn( submissions: AgentJournalSubmission[] = [] ): string[] { return createNativeChatMessageListProjection()( - projectStructuredAgentSessionMessages(items, outbox, submissions, NO_CARDS) + projectStructuredAgentSessionMessages(items, outbox, submissions) ).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 28ef88564b9..f1cd44528a7 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 @@ -9,7 +9,6 @@ import { useStructuredAgentSessionDeliveryNotices } from './use-structured-agent afterEach(cleanup) const NONE = new Set() -const NO_CARDS: readonly string[] = [] const EMPTY: never[] = [] function rejected( @@ -65,7 +64,6 @@ it('keeps the same notices across batches in a chat whose only rejection a Stop submissions, journalItems: EMPTY, failedHere: NONE, - queuedMessageIds: NO_CARDS, retry: () => {}, agentName: 'Claude' }), @@ -87,7 +85,6 @@ function renderNotices(submissions: readonly AgentJournalSubmission[]) { submissions: current, journalItems: EMPTY, failedHere: NONE, - queuedMessageIds: NO_CARDS, retry: () => {}, agentName: 'Claude' }), 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 2c39d306e82..e1a3e42c427 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 @@ -19,11 +19,10 @@ export function useStructuredAgentSessionDeliveryNotices(args: { 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 + const { agentName, failedHere, outbox, 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(() => { @@ -54,7 +53,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { journalRows, startFailures, failedHere, - queuedMessageIds, commandItemIds, loadedItems ), @@ -65,7 +63,6 @@ export function useStructuredAgentSessionDeliveryNotices(args: { journalRows, startFailures, failedHere, - queuedMessageIds, commandItemIds, loadedItems ] 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 419e6a6cae9..68e388de58c 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,7 +11,6 @@ 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, @@ -26,8 +25,7 @@ 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, NO_CARDS), + (items: AgentJournalRenderItem[]) => useStructuredAgentSessionMessages(items, EMPTY, EMPTY), { initialProps: [first, second] } ) const initial = result.current @@ -52,9 +50,7 @@ it('retains only unchanged item projections across updates, reorder, deletion, a [structuredClone(completed)] ]) { rerender(items) - expect(result.current).toEqual( - projectStructuredAgentSessionMessages(items, EMPTY, EMPTY, NO_CARDS) - ) + expect(result.current).toEqual(projectStructuredAgentSessionMessages(items, EMPTY, EMPTY)) expect(result.current.find((message) => message.id === 'second')).not.toBe(initial[1]) } const replacement = { @@ -66,9 +62,7 @@ it('retains only unchanged item projections across updates, reorder, deletion, a } } rerender([replacement]) - expect(result.current).toEqual( - projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY, NO_CARDS) - ) + expect(result.current).toEqual(projectStructuredAgentSessionMessages([replacement], EMPTY, EMPTY)) expect(result.current[0]).not.toBe(initial[0]) }) @@ -97,14 +91,14 @@ it('keeps optimistic sends and their settlement identical to uncached projection }: { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] - }) => useStructuredAgentSessionMessages(items, [entry], submissions, NO_CARDS), + }) => useStructuredAgentSessionMessages(items, [entry], submissions), { initialProps: { items: [tool('tool', 1)], submissions: [submission] } } ) for (const dispatchState of ['pending', 'unknown', 'accepted'] as const) { const props = { items: [tool('tool', 1)], submissions: [{ ...submission, dispatchState }] } rerender(props) expect(result.current).toEqual( - projectStructuredAgentSessionMessages(props.items, [entry], props.submissions, NO_CARDS) + projectStructuredAgentSessionMessages(props.items, [entry], props.submissions) ) } }) @@ -112,7 +106,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, NO_CARDS) + useStructuredAgentSessionMessages(items, EMPTY, EMPTY) ) 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 af38cda41df..f965c2d3eaa 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,11 +9,10 @@ import { projectStructuredAgentSessionMessages } from './structured-agent-sessio export function useStructuredAgentSessionMessages( items: readonly AgentJournalRenderItem[], outbox: readonly StructuredAgentSessionOutboxEntry[], - submissions: readonly AgentJournalSubmission[], - queuedMessageIds: readonly string[] + submissions: readonly AgentJournalSubmission[] ) { return useMemo( - () => projectStructuredAgentSessionMessages(items, outbox, submissions, queuedMessageIds), - [items, outbox, submissions, queuedMessageIds] + () => projectStructuredAgentSessionMessages(items, outbox, submissions), + [items, outbox, submissions] ) } 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 fadb4440534..69f802b060d 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 @@ -29,9 +29,12 @@ 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' +import { readOutbox, writeOutbox } from './structured-agent-session-outbox-storage' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] @@ -183,6 +186,147 @@ describe('outbox queue delivery selection', () => { await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) }) + // After a quit, the outbox sends an unconfirmed message again under its own id, to a host that + // does not queue, and the host has kept that message as a held card under the same id. Its reply + // is the send's own rejected record; the card is what says the host holds it. + it.each(['reply first', 'card first'] as const)( + 'a send a restart kept as a card leaves the outbox with no Retry and no restore (%s)', + async (order) => { + let answer: (value: unknown) => void = () => undefined + mocks.call.mockImplementation(() => new Promise((resolve) => (answer = resolve))) + const view = renderHook( + (props: { queuedMessageIds: string[] }) => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + composerScopeKey: 'kept-scope', + queueDelivery: { capability: 'unsupported', enabled: true }, + queuedMessageIds: props.queuedMessageIds + }), + { initialProps: { queuedMessageIds: Array.of() } } + ) + expect(view.result.current.send('kept by the host')).toBe(true) + const id = (await sentParams()).envelope.clientOperationId + const rejectedReplay = { + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId: id, + submission: { + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true, + handoverRecorded: true + } + } + } + if (order === 'reply first') { + await act(async () => answer(rejectedReplay)) + await waitFor(() => expect(view.result.current.outbox[0]?.state).toBe('rejected')) + view.rerender({ queuedMessageIds: [id] }) + } else { + view.rerender({ queuedMessageIds: [id] }) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + await act(async () => answer(rejectedReplay)) + } + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readOutbox('session-1')).toEqual([]) + expect(readNativeChatDraftCache('kept-scope')).toBe('') + expect(mocks.call).toHaveBeenCalledTimes(1) + } + ) + + // The card may be sent, edited or deleted on another device before this desktop ever sees it: + // the send's own record says the host kept it, so its local copy leaves with no Retry. + describe('a send the host kept as a card, its card never seen here', () => { + function keptRejection(id: string): AgentJournalSubmission { + return { + clientMessageId: id, + fence: 1, + payloadFingerprint: 'fp', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true, + handoverRecorded: true, + keptAsQueuedMessageId: id + } + } + + it('leaves the outbox on the host’s answer', async () => { + mocks.call.mockImplementation(async (_target, _method, params) => ({ + ok: true, + replayed: true, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + clientMessageId: params.envelope.clientOperationId, + submission: keptRejection(params.envelope.clientOperationId) + } + })) + const view = renderHook(() => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [], + composerScopeKey: 'kept-elsewhere', + queueDelivery: { capability: 'unsupported', enabled: true } + }) + ) + expect(view.result.current.send('kept, then deleted elsewhere')).toBe(true) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readOutbox('session-1')).toEqual([]) + expect(readNativeChatDraftCache('kept-elsewhere')).toBe('') + }) + + it('leaves the outbox when the journal shows it, from a stored not-sent copy', async () => { + writeOutbox('session-1', [ + { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'kept-id', + sessionId: 'session-1', + text: 'kept, then deleted elsewhere', + attachments: [], + queuedAt: 1 + }), + state: 'unconfirmed', + lastAttemptAt: 5 + } + ]) + const view = renderHook(() => + useStructuredAgentSessionOutbox({ + journalItems: NO_JOURNAL_ITEMS, + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [keptRejection('kept-id')], + queueDelivery: { capability: 'unsupported', enabled: true } + }) + ) + await waitFor(() => expect(view.result.current.outbox).toHaveLength(0)) + expect(readOutbox('session-1')).toEqual([]) + expect(mocks.call).not.toHaveBeenCalled() + }) + }) + it("Stop's local step never restores a queued send already in flight — its answer settles it", async () => { // The send is on its way; the Stop lands behind it, so the host may already hold // it as a paused card. Restoring it locally too would double the text. 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 bfebc877b2c..5e736e1dcfe 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 @@ -31,8 +31,6 @@ import { useStructuredAgentSessionOutbox } from './use-structured-agent-session- const NO_JOURNAL_ITEMS: readonly AgentJournalRenderItem[] = [] -const NO_CARDS: readonly string[] = [] - afterEach(cleanup) beforeEach(() => { @@ -91,7 +89,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], NO_CARDS) + projectStructuredAgentSessionMessages([hostItem], outbox, [submission]) .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-queued-messages.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts index bd445b6e2ce..748487986a3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.ts @@ -26,6 +26,9 @@ import type { StructuredAgentSessionMutate } from './use-structured-agent-sessio export type StructuredAgentSessionQueuedMessagesController = { cards: QueuedMessageCard[] + /** The host queues sends. Without it a card can still show — a message the host kept unsent — + * but queueing settings and the steer chord would do nothing. */ + queueCapable: boolean /** Why the whole queue sends nothing on its own; null when it drains. Shown only with cards. * A string reason: a newer host may name one this build does not know. */ pause: { reason: string } | null @@ -183,5 +186,15 @@ export function useStructuredAgentSessionQueuedMessages(args: { return true }, [enabled, steer]) - return { cards, pause, resume, resuming, steer, remove, edit, steerNewest } + return { + cards, + queueCapable: enabled, + pause, + resume, + resuming, + steer, + remove, + edit, + steerNewest + } } 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 index b37350cb39d..025aba64fb8 100644 --- 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 @@ -1,7 +1,7 @@ // @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. +// A send the host kept as a card is drawn as that card, never also as a not-sent row, and its +// card's Edit or Delete brings no row back: the transcript reads the send's own record. import { renderHook } from '@testing-library/react' import { beforeEach, expect, it, vi } from 'vitest' @@ -15,6 +15,7 @@ import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../../shared/structured-agent-session-dispatch-rejection' let queuedMessages: AgentSessionQueuedMessage[] = [] +let submissions: AgentJournalSubmission[] = [] const KEPT_BODY: AgentJournalMessageItem = { kind: 'message', @@ -52,7 +53,7 @@ vi.mock('./use-structured-agent-session-read', () => ({ state: { fence: 3, items: [KEPT_ITEM], - submissions: [KEPT_SUBMISSION], + submissions, status: 'ready', error: null, hasOlder: false, @@ -104,13 +105,19 @@ function keptRows(): { id: string; unsent?: true }[] { beforeEach(() => { queuedMessages = [] + submissions = [{ ...KEPT_SUBMISSION, keptAsQueuedMessageId: 'kept' }] }) -it('draws no row for a rejected message while a card holds it under its id', () => { +it('draws no row for a kept send while its card is there', () => { queuedMessages = [card('kept')] expect(keptRows()).toEqual([]) }) -it('draws it as not sent once no card holds it', () => { +it('draws no row for a kept send once its card was edited or deleted', () => { + expect(keptRows()).toEqual([]) +}) + +it('draws a rejected send the host did not keep as not sent', () => { + submissions = [KEPT_SUBMISSION] expect(keptRows()).toEqual([{ id: KEPT_ITEM.itemId, unsent: true }]) }) 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 3768a56a5b8..ef5a3db46c1 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 @@ -114,8 +114,7 @@ export function useStructuredAgentSession(args: { mutate, ...(launch ? { launch } : {}) }) - // Only a capable host may see `delivery` or the queuedMessage RPCs; against - // anything older this client must look exactly like today's. + // Only a capable host may see `delivery`; a card any host publishes shows, with its actions. const queueCapability = useStructuredAgentSessionHostQueuesMessagesState(target) const queueCapable = queueCapability === 'supported' const queuedMessageIds = useMemo( @@ -204,8 +203,7 @@ export function useStructuredAgentSession(args: { const messages = useStructuredAgentSessionMessages( transcriptItems, transcriptOutbox, - transportState.submissions, - queuedMessageIds + transportState.submissions ) const queuedController = useStructuredAgentSessionQueuedMessages({ enabled: queueCapable && transportState.fence !== null, @@ -252,8 +250,6 @@ 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/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f9071f05610..7ff593c472d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18312,6 +18312,7 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", + "pausedKept": "Not sent yet — press Send to send it.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", "queuePausedRestarted": "Queue paused because Orca restarted", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0da94aca473..9708bdc6ba1 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17943,6 +17943,7 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", + "pausedKept": "Aún no se ha enviado. Pulsa Enviar para enviarlo.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", "queuePausedRestarted": "Cola en pausa porque Orca se reinició", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 1213c89d445..3bc1a96946e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18160,6 +18160,7 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", + "pausedKept": "Pas encore envoyé. Appuyez sur Envoyer pour l'envoyer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 066c752ceb8..fda99065b37 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18096,6 +18096,7 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", + "pausedKept": "まだ送信されていません。「送信」を押すと送信されます。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a043ec36ea9..e06aba01d15 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18096,6 +18096,7 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", + "pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3af1cb962ed..36efa599608 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18061,6 +18061,7 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", + "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index e083eadb57a..0de74130a3e 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -102,8 +102,8 @@ export function useStructuredAgentSessionHostRecoversRewindOnSend( return useStructuredAgentSessionHostCapability(target, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY) } -/** Whether the host holds mid-turn sends as drafts: only then may a client send `delivery` - * or call the queuedMessage RPCs. */ +/** Whether the host holds mid-turn sends as drafts: only then may a client send `delivery`. The + * published cards and their queuedMessage actions are not gated on it. */ export function useStructuredAgentSessionHostQueuesMessages(target: RuntimeClientTarget): boolean { return useStructuredAgentSessionHostQueuesMessagesState(target) === 'supported' } diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 594d6a04b11..10dac12611b 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -3,9 +3,9 @@ import { AgentJournalItemBodySchema, isAdmissibleAgentJournalItemBody, isAdmissibleAgentJournalMessageBody, - isAdmissibleAgentJournalRenderItem, - isAdmissibleAgentJournalSubmission + isAdmissibleAgentJournalRenderItem } from './agent-session-journal-schemas' +import { isAdmissibleAgentJournalSubmission } from './agent-session-journal-submission-schema' import type { AgentJournalItemBody, AgentJournalRenderItem, diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index a2070706b9e..89118307dd6 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -28,14 +28,12 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' -import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union' import type { AgentJournalItemBody, AgentJournalMessageItem, AgentJournalResolution, - AgentJournalRenderItem, - AgentJournalSubmission + AgentJournalRenderItem } from './agent-session-journal-types' const BoundedPayload = z.object({ @@ -310,25 +308,6 @@ export const AgentJournalRenderItemSchema = z.object({ ...AgentJournalProducerLinkageFields }) -export const AgentJournalSubmissionSchema = z.object({ - clientMessageId: z.string().min(1), - fence: z.number().int(), - payloadFingerprint: z.string(), - dispatchState: z.string().min(1), - providerItemId: z.string().nullable(), - reason: z.string().nullable(), - submittedAt: z.number(), - resolvedAt: z.number().nullable(), - submittedSequence: z.number().int().optional(), - answeredInTurn: AgentJournalAnsweredTurnSchema.optional(), - recovered: z.literal(true).optional(), - handoverRecorded: z.literal(true).optional(), - handedOverAt: z.number().optional(), - rejection: AgentSessionFailureFactSchema.optional(), - // Listed, or the parse strips it: this schema drops unknown keys. - queuedMessageId: z.string().min(1).optional() -}) - export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution { return Resolution.safeParse(value).success } @@ -350,12 +329,6 @@ export function isAdmissibleAgentJournalRenderItem( return AgentJournalRenderItemSchema.safeParse(value).success } -export function isAdmissibleAgentJournalSubmission( - value: unknown -): value is AgentJournalSubmission { - return AgentJournalSubmissionSchema.safeParse(value).success -} - /** Compile-time proof that every canonical value is admissible, so replay can * never reject a row a writer in this build produced. The schemas are * deliberately wider on open string fields, so only this direction holds. */ @@ -363,8 +336,5 @@ type Admits = T export type CanonicalJournalTypesAreAdmissible = [ Admits ? true : false>, Admits ? true : false>, - Admits< - AgentJournalRenderItem extends z.input ? true : false - >, - Admits ? true : false> + Admits ? true : false> ] diff --git a/src/shared/agent-session-journal-submission-schema.test.ts b/src/shared/agent-session-journal-submission-schema.test.ts new file mode 100644 index 00000000000..56414411c77 --- /dev/null +++ b/src/shared/agent-session-journal-submission-schema.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + AgentJournalSubmissionSchema, + isAdmissibleAgentJournalSubmission +} from './agent-session-journal-submission-schema' + +// Persisted and published: a reader that keeps the parsed value must not lose either fact. +it('keeps a kept send’s card id and its source through a parse', () => { + const kept: AgentJournalSubmission = { + clientMessageId: 'client-kept', + fence: 2, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + recovered: true, + source: { kind: 'user' }, + keptAsQueuedMessageId: 'client-kept' + } + expect(isAdmissibleAgentJournalSubmission(kept)).toBe(true) + expect(AgentJournalSubmissionSchema.parse(kept)).toEqual(kept) + expect(AgentJournalSubmissionSchema.parse(JSON.parse(JSON.stringify(kept)))).toEqual(kept) +}) + +it('refuses an empty card id rather than reading it as kept', () => { + expect( + isAdmissibleAgentJournalSubmission({ + clientMessageId: 'client-kept', + fence: 2, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + keptAsQueuedMessageId: '' + }) + ).toBe(false) +}) diff --git a/src/shared/agent-session-journal-submission-schema.ts b/src/shared/agent-session-journal-submission-schema.ts new file mode 100644 index 00000000000..0eeffb13a17 --- /dev/null +++ b/src/shared/agent-session-journal-submission-schema.ts @@ -0,0 +1,42 @@ +// The deep validator for a journal submission (`AgentJournalSubmission`), the row a sent message's +// dispatch state lives on. Open string fields stay type-checked, never enum-checked, as in +// `agent-session-journal-schemas.ts`. + +import { z } from 'zod' +import { AgentJournalAnsweredTurnSchema } from './agent-session-answered-turn-schema' +import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' +import type { AgentJournalSubmission } from './agent-session-journal-types' + +// Every persisted field is listed, or a parse strips it: this schema drops unknown keys. +export const AgentJournalSubmissionSchema = z.object({ + clientMessageId: z.string().min(1), + fence: z.number().int(), + payloadFingerprint: z.string(), + dispatchState: z.string().min(1), + providerItemId: z.string().nullable(), + reason: z.string().nullable(), + submittedAt: z.number(), + resolvedAt: z.number().nullable(), + submittedSequence: z.number().int().optional(), + answeredInTurn: AgentJournalAnsweredTurnSchema.optional(), + recovered: z.literal(true).optional(), + handoverRecorded: z.literal(true).optional(), + handedOverAt: z.number().optional(), + rejection: AgentSessionFailureFactSchema.optional(), + queuedMessageId: z.string().min(1).optional(), + // The kind only; an object's other keys (a sender) are stripped, never published. + source: z.object({ kind: z.string() }).optional(), + keptAsQueuedMessageId: z.string().min(1).optional() +}) + +export function isAdmissibleAgentJournalSubmission( + value: unknown +): value is AgentJournalSubmission { + return AgentJournalSubmissionSchema.safeParse(value).success +} + +/** Compile-time proof that every submission this build writes is admissible. */ +type Admits = T +export type CanonicalJournalSubmissionIsAdmissible = Admits< + AgentJournalSubmission extends z.input ? true : false +> diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index d2638fd9182..ff5d53aca76 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -478,6 +478,15 @@ export type AgentJournalSubmission = { * A person's turn is what ends a Stop's queue pause. The snapshot still carries it; no released * client reads it. */ origin?: 'client' | 'host' + /** Who it is from: the kind of its `AgentSessionMessageSource` ('user' or 'agent'), so a restart + * or a close keeps only a person's unsent send as a card. Only the kind: the senders stay on the + * card, host-only, and publishing them here would need a strip. A newer build's kind is kept as + * written, never read as absent. Absent when its sender named none (a dispatch preamble, a restart continuation). */ + source?: { kind: string } + /** On a rejected send the host kept as a card: that card's message id. The text lives on the + * card, so no surface draws this send, before or after the card is sent, edited or deleted. + * Recorded in the rejection's own transaction (`journal-unsent-send-hold.ts`). */ + keptAsQueuedMessageId?: string } /** Durable answer to "did my send land?", keyed by client message id. Only an diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index f7659c95873..6a2829476d6 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -7,7 +7,13 @@ import type { AgentJournalMessageItem } from './agent-session-journal-types' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const -export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED +/** A person's message the host accepted and never handed over before a restart or a close of the + * chat. It waits for the person's own Send; the cards behind it still send, as past a failed one. */ +export const QUEUED_MESSAGE_PAUSED_KEPT = 'kept' as const + +export type AgentSessionQueuedMessagePausedReason = + | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED + | typeof QUEUED_MESSAGE_PAUSED_KEPT /** The whole queue is paused and sends nothing on its own: 'stopped' — the user * interrupted ("Queue paused because you interrupted") — 'cleared' — a /clear @@ -30,11 +36,11 @@ export type AgentSessionQueuedMessage = { position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' - /** This one card is held, whatever the queue's pause: its conversion failed. */ + /** This one card is held, whatever the queue's pause: its conversion failed, or the host kept it. */ paused?: true /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't - * send"; only an explicit Send releases it). A client must treat an unknown - * marker as a plain hold, so a newer host can add one. The queue-level + * send") or 'kept' (accepted, never sent); only an explicit Send releases either. A client + * must treat an unknown marker as a plain hold, so a newer host can add one. The queue-level * pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 2b34d58bb05..87f59b2f10f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -215,14 +215,16 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = export const AGENT_SESSION_SEND_ANSWERS_PROOF_RUNTIME_CAPABILITY = 'agent-session.send-answers-proof.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a -// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON -// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer -// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue. +// capable client can render the `queued` result arm. It gates `delivery: 'queue-if-active'` and +// the client's queueing setting and chord, never the published draft list or its card actions: a +// host without it still publishes a message it kept unsent across a restart or a close, and both +// clients show that card and call its actions. DARK ON PURPOSE — not in RUNTIME_CAPABILITIES: +// advertising still requires the integrated Codex steer matrix (#21062) in the shipped host. // v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and // never compares a draft id with a submission id. It also publishes the queue's pause once, as // `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next -// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant -// gates the rollout. +// turn; a card carries a hold of its own when its conversion failed (`send_failed`) or the host +// kept it unsent (`kept`). The host mechanism lands first; the constant gates the rollout. export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY = 'agent-session.queued-messages.v1' as const // Why: paired clients advertise Claude-structured support so the host can gate its agent-specific diff --git a/src/shared/structured-agent-session-message-projection.test.ts b/src/shared/structured-agent-session-message-projection.test.ts new file mode 100644 index 00000000000..c0829aa02b6 --- /dev/null +++ b/src/shared/structured-agent-session-message-projection.test.ts @@ -0,0 +1,85 @@ +// A send the host kept as a card is drawn only as that card: the rule rides on the submission's +// own fact, never on the card still being there. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' +import { createStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +const KEPT_ID = 'client-kept' +// The desktop draws a rejected send in place, as not sent, unless the host kept it as a card. +const DESKTOP = { rejectedInPlace: true } + +function rejected(fields: Partial = {}): AgentJournalSubmission { + return { + clientMessageId: KEPT_ID, + fence: 1, + payloadFingerprint: 'fingerprint-kept', + dispatchState: 'rejected', + providerItemId: null, + reason: 'Orca restarted before this message was sent.', + rejection: { kind: 'hostRestarted' }, + submittedAt: 1, + resolvedAt: 2, + ...fields + } +} + +function userItem(id: string, text: string, sequence: number): AgentJournalRenderItem { + return { + itemId: agentJournalSubmissionKey(id), + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } +} + +/** The sending desktop's own copy, still marked not sent. */ +const lingeringCopy = { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: KEPT_ID, + sessionId: 'session-1', + text: 'the kept words', + attachments: [], + queuedAt: 1 + }), + state: 'rejected' as const +} + +describe('a send kept as a card', () => { + it('shows nothing of the original send, even beside its own local copy', () => { + const kept = rejected({ keptAsQueuedMessageId: KEPT_ID }) + const items = [userItem(KEPT_ID, 'the kept words', 1)] + expect(projectStructuredAgentSessionMessages(items, [lingeringCopy], [kept], DESKTOP)).toEqual( + [] + ) + // A rejection with no card keeps its local copy, marked not sent. + expect( + projectStructuredAgentSessionMessages(items, [lingeringCopy], [rejected()], DESKTOP) + ).toEqual([expect.objectContaining({ id: agentJournalSubmissionKey(KEPT_ID), unsent: true })]) + }) + + // Edit is the card's text in the composer, then the card's Delete, then a new send. + it('after an Edit sent again, shows exactly the new send', () => { + const kept = rejected({ keptAsQueuedMessageId: KEPT_ID }) + const edited: AgentJournalSubmission = { + ...rejected(), + clientMessageId: 'client-edited', + dispatchState: 'accepted', + providerItemId: 'provider-edited', + reason: null + } + delete edited.rejection + const shown = projectStructuredAgentSessionMessages( + [userItem(KEPT_ID, 'the kept words', 1), userItem('client-edited', 'the edited words', 2)], + [lingeringCopy], + [kept, edited], + DESKTOP + ) + expect(shown).toHaveLength(1) + expect(shown[0]).toMatchObject({ blocks: [{ text: 'the edited words' }] }) + expect(shown[0]).not.toHaveProperty('unsent') + }) +}) diff --git a/src/shared/structured-agent-session-message-projection.ts b/src/shared/structured-agent-session-message-projection.ts index 57a2f6fe15d..6d656ea560d 100644 --- a/src/shared/structured-agent-session-message-projection.ts +++ b/src/shared/structured-agent-session-message-projection.ts @@ -13,8 +13,6 @@ 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 - /** The queue's live cards: a rejected message one of them holds is drawn there, not here. */ - queuedMessageIds?: readonly string[] } /** The loaded items that are a conversation command such as `/compact`, by item id. */ @@ -31,15 +29,13 @@ export function structuredAgentSessionCommandItemIds( /** * 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, 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. + * queue holds (a draft's hand-off, or a send kept as a card) 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[], 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 // supersedes nothing. const copies = new Map() @@ -61,7 +57,8 @@ export function structuredAgentSessionRejectedShownInPlace( submission.dispatchState !== 'rejected' || dispatchWasWithdrawn(submission) || submission.queuedMessageId !== undefined || - cards.has(submission.clientMessageId) || + // Recorded on the send itself, so an Edit or Delete of its card brings no row back. + submission.keptAsQueuedMessageId !== undefined || 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 @@ -95,7 +92,6 @@ export function projectStructuredAgentSessionMessages( const inPlace = options.rejectedInPlace ? structuredAgentSessionRejectedShownInPlace( submissions, - options.queuedMessageIds ?? [], structuredAgentSessionCommandItemIds(items) ) : new Set() diff --git a/src/shared/structured-agent-session-outbox-reconcile.test.ts b/src/shared/structured-agent-session-outbox-reconcile.test.ts index 0336bd649e4..64417e6c3db 100644 --- a/src/shared/structured-agent-session-outbox-reconcile.test.ts +++ b/src/shared/structured-agent-session-outbox-reconcile.test.ts @@ -98,3 +98,12 @@ it('returns the list it was given when nothing changes', () => { entries ) }) + +// The card carries the text from here; the copy would draw a "Not sent" row the transcript hides. +it('drops the copy of a send the host kept as a card, with its row not yet loaded', () => { + const kept = { ...submission('kept', 'rejected'), keptAsQueuedMessageId: 'kept' } + expect(reconcileStructuredAgentSessionOutbox([entry('kept')], [kept], [])).toEqual([]) + expect( + reconcileStructuredAgentSessionOutbox([entry('kept')], [submission('kept', 'rejected')], []) + ).toEqual([expect.objectContaining({ clientMessageId: 'kept', state: 'rejected' })]) +}) diff --git a/src/shared/structured-agent-session-outbox-reconcile.ts b/src/shared/structured-agent-session-outbox-reconcile.ts index 9fa699eece1..abcd34d9d67 100644 --- a/src/shared/structured-agent-session-outbox-reconcile.ts +++ b/src/shared/structured-agent-session-outbox-reconcile.ts @@ -27,6 +27,13 @@ export function reconcileStructuredAgentSessionOutbox( if (submission?.dispatchState === 'accepted' || dispatchWasWithdrawn(submission)) { return [] } + // The host kept it as a card, which carries the text from here, edited or deleted included. + if ( + submission?.dispatchState === 'rejected' && + submission.keptAsQueuedMessageId !== undefined + ) { + 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. diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 77e1030d4b2..3dad3dc4dc0 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -274,6 +274,14 @@ export function disposeStructuredAgentSessionSendResult( error: null } } + // The host kept it as a card, first reply or replay: the card owns the text, so the entry + // leaves as the reconcile drops it, with no Retry that could send words the card's Delete took. + if (submission.dispatchState === 'rejected' && submission.keptAsQueuedMessageId !== undefined) { + return { + entries: dropEntry(input), + 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') { diff --git a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts new file mode 100644 index 00000000000..86eed2b8271 --- /dev/null +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -0,0 +1,227 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity, + AgentSessionJournalProviderHandle +} from '../../../src/shared/agent-session-journal-types' +import { claudeProviderHandle } from '../../../src/shared/agent-session-provider-handle-encoding' +import { USER_MESSAGE_SOURCE } from '../../../src/shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' +import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../src/shared/agent-session-queued-message-wire' +import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { holdUnsentSends } from '../../../src/main/native-chat/agent-session-journal/journal-unsent-send-hold' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +// A kept send is an ordinary waiting card held by a new value in its existing `hold_reason` +// column, placed ahead of the queue: no new state or column. A build with the queue but without +// this change reads an unknown hold as a plain one: it must list the card first and never send it, +// even after a person's turn ends a restart's pause, and must still settle a send this build's quit +// left queued. One exception lives in that build's own code: its /clear carries every card over +// without its hold, so the replacement's next turn sends a carried kept card. The main commit this change branched from, which has the queue; move it to the +// newest release that has the queue and predates this change. A baseline holding this change tests +// no downgrade. +const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' +const JOURNAL = 'src/main/native-chat/agent-session-journal' +const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' +}) + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-kept-downgrade', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) +} + +/** The identity as builds before the neutral provider handle took it. */ +type OlderJournalIdentity = Omit & { + providerHandle: AgentSessionJournalProviderHandle +} + +const OLDER_IDENTITY: OlderJournalIdentity = { + ...IDENTITY, + providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null } +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +type OlderCard = { + messageId: string + state: string + position: number + hostInstance: string + holdReason: string | null +} + +type OlderJournal = { + queuedMessages: { + list: () => readonly OlderCard[] + pauses: (hostInstance: string) => { reason: string }[] + adopt: (hostInstance: string) => Promise + } + submission: (id: string) => { dispatchState: string; reason: string | null } | undefined + wroteBeforeOpen: (sequence: number | undefined) => boolean + rejectQueuedSubmissions: ( + fence: number, + rejection: typeof HOST_RESTARTED, + which: (submission: { acceptedSequence?: number }) => boolean + ) => Promise + repair: { malformedRows: number } +} + +type OlderOpener = { + open: (options: { + identity: OlderJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise +} + +type OlderNextSendable = ( + pauses: readonly { reason: string }[], + cards: readonly OlderCard[] +) => OlderCard | null + +/** The pinned build's drain pick (`nextSendableQueuedCard`): the card it would send next. */ +async function olderNextSendable(): Promise { + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const pause = await importReleaseCheckoutModule(checkout, `${JOURNAL}/queued-message-pause.ts`) + const next = pause.nextSendableQueuedCard + if (typeof next !== 'function') { + throw new Error('the pinned build exports no nextSendableQueuedCard') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned build's own drain pick, called with that build's own pauses and cards. + return next as OlderNextSendable +} + +async function olderOpener(): Promise { + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const create = support.createTrackedJournalOpener + if (typeof create !== 'function') { + throw new Error('the pinned build exports no createTrackedJournalOpener') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned build's own test opener; each member read here is named in `OlderOpener`, and a changed one fails the test. + return create() as OlderOpener +} + +async function acceptPersonSend( + journal: Awaited['open']>>, + id: string +): Promise { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: message(id), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) +} + +test('an older build lists a kept card first and never sends it, even after a person’s turn', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-kept-card-downgrade-')) + const journals = createTrackedJournalOpener() + try { + const earlier = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await earlier.queuedMessages.insert({ + messageId: 'queued-card', + body: message('queued-card'), + fingerprint: 'fp-queued-card', + hostInstance: 'host-a', + source: USER_MESSAGE_SOURCE + }) + await acceptPersonSend(earlier, 'kept') + await journals.closeAll() + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await holdUnsentSends(reopened, { + fence: 0, + hostInstance: 'host-b', + hold: { cause: 'hostRestarted' } + }) + expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ + 'kept', + 'queued-card' + ]) + await journals.closeAll() + + const older = await olderOpener() + const nextSendable = await olderNextSendable() + try { + const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) + const cards = () => + downgraded.queuedMessages.list().map(({ messageId, state, holdReason }) => ({ + messageId, + state, + holdReason + })) + expect(cards()).toEqual([ + { messageId: 'kept', state: 'waiting', holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { messageId: 'queued-card', state: 'waiting', holdReason: null } + ]) + expect(downgraded.queuedMessages.list()[0]!.position).toBeLessThan(1) + expect(downgraded.submission('kept')).toMatchObject({ dispatchState: 'rejected' }) + // A person's turn there adopts every card into its process, which ends the restart's + // pause; the kept card's hold survives it, so its drain never picks it. That build skips a + // held card as it skips a failed send's, so the card behind it is the one it sends. + expect(await downgraded.queuedMessages.adopt('host-c')).toBe(true) + expect(cards()[0]).toMatchObject({ + messageId: 'kept', + holdReason: QUEUED_MESSAGE_PAUSED_KEPT + }) + expect(downgraded.queuedMessages.pauses('host-c')).toEqual([]) + expect( + nextSendable(downgraded.queuedMessages.pauses('host-c'), downgraded.queuedMessages.list()) + ?.messageId + ).toBe('queued-card') + } finally { + await older.closeAll() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000) + +test("an older build reads the send this build's quit left queued, its source included, and settles it", async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-kept-leftover-downgrade-')) + const journals = createTrackedJournalOpener() + try { + const quitting = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await acceptPersonSend(quitting, 'left-queued') + await journals.closeAll() + + const older = await olderOpener() + try { + const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) + expect(downgraded.repair).toEqual({ malformedRows: 0 }) + expect(downgraded.submission('left-queued')).toMatchObject({ dispatchState: 'pending' }) + // Its delivery loop's first step, as that build runs it: rejected, never handed over. + expect( + await downgraded.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + downgraded.wroteBeforeOpen(submission.acceptedSequence) + ) + ).toEqual(['left-queued']) + expect(downgraded.submission('left-queued')).toMatchObject({ + dispatchState: 'rejected', + reason: HOST_RESTARTED.reason + }) + } finally { + await older.closeAll() + } + } finally { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) + } +}, 120_000)