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 01/71] 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 57f2fa410ffe..4b53ee81556d 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 3e0371805306..90ebecb42ebb 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 136043447899..01b8c7d4bc23 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 44d1e0b9729f..987d9aeefd48 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 f3edb13e53c8..843292e44210 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 8431e32a7aa9..278142b271cc 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 f20dc0971887..bc0db996a93d 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 277beb17d75a..fe7f7f1eb14a 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 7bec053c9d67..1e1658511bce 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 4e033c6dd972..f65ecf2d46e3 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 3625982a24fa..519a62fb3e18 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 7f9711151fc2..7c6deabef407 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 7e1d6949bf35..6b3e53f36b37 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 8c190196bdfa..902f565b6173 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 36a7d7262e8b..cba9c9a9bd51 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 9d436e1eb547..d2a95a6db724 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 1995b2e98751..b4dbe3735ba7 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 c2547c658c88..2d7b56d06b1f 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 626b77cf6140..9b676cb26b38 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 b00bdd36de24..b93ad8c6fcf7 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 d0306fa94679..dda3cd716956 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 000000000000..c79fa6030315 --- /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 000000000000..f559f07db12a --- /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 000000000000..f08d8bb05c4c --- /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 00b90a832c1d..05867436e12e 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 5989a10e9675..f9bf0fb2d0bb 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 63dc6823df28..de3b702067c9 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 000000000000..9916e6fc31b6 --- /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 cf18a1e5006c..46717268ad10 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 8d2e9ec0acce..029bdb74e6f3 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 a9c0ef7c70cc..264d17b572a1 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 000000000000..f5a10c42073f --- /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 1a82925f1f9a..d68341888a6f 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 99900a2a8935..7f7eb05fe1b2 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 30c4fbbf933d..4033c70de1c3 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 dc3d4fc42ae7..9c556916a39b 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 e3aaa965edc9..3d5d37050439 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 80460bb72fa9..d1bb1cfa7944 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 5e8d7e2097f9..fda7bc9c27e6 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 ab632de9dda8..ddfd7c25ff4a 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 bbae1b4db163..fc93e4a43d44 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 0b6a1fd89cf9..b82ece68435b 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 6a2a6268ae94..6190d74b51b0 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 6f0c544f377c..2551cd33237c 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 000000000000..864ad55f13cf --- /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 668209d15138..bf2d1a5575e2 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 d4c7c470f12a..3d451a72e0f1 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 8554bb081546..0eb300fdef81 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 1fa46616da18..50f3b9ef270e 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 9abdc713f0e1..d27d521c40e8 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 4dff44ff2abc..49d3d849ec3a 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 4019582e1fde..23d2e5897761 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 a1bb644d84e0..d53d06f35a00 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 2d5a9a133e4c..8956e2d84fce 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 000000000000..0219207a6ccb --- /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 217360673fce..9f42431e7730 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 22fa2b415284..d3bb13bafa36 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 0f92d95cee8d..9ba15ea1765c 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 1b5910519361..55717cca96b5 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 00c779fffe0b..e18f557163db 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 b501acc74c60..02797d54d6c8 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 a4b68ccbcd3a..259ee2dbe148 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 d86b5bc394bb..06bc89d8c16c 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 3578cec8f0f4..28affc5512d5 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 41fd4852a202..84d6dc4480aa 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 9c47a8c375e1..b0960a4410b4 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 8f43b043c81a..7c8d6cf5d22b 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 16c9a30a3972..6d4e0b23a784 100644 --- a/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-rail-outline-parity.test.ts @@ -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 000000000000..3e636a0e558a --- /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 9054c35b5335..3ed26650ed42 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 52f3832dffaf..ef91f8c1920f 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 7cc13a0a4eb6..0c5d273bc54b 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 84e10ca18b7b..c27204b91f53 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 4e7577052256..4d94622cfa99 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('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('kept'), text: 'kept text', unsent: true } + { 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 bd99e8f8cbbf..1108d468739b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -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 f527442c2b15..cb620ed72d89 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 995d80247699..4dc58abf4908 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 3b12ed7b3dcb..aa774f7fbf3d 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 28ef88564b90..f1cd44528a7b 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 2c39d306e82a..e1a3e42c4278 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 419e6a6cae9b..68e388de58c5 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 af38cda41df1..f965c2d3eaa3 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 fadb44405340..69f802b060de 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 bfebc877b2c8..5e736e1dcfe2 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 bd445b6e2ce9..748487986a3e 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 b37350cb39dd..025aba64fb8b 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 3768a56a5b84..ef5a3db46c18 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 f9071f056100..7ff593c472d1 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 0da94aca4732..9708bdc6ba15 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 1213c89d4458..3bc1a96946ed 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 066c752ceb8f..fda99065b37b 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 a043ec36ea99..e06aba01d150 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 3af1cb962ed7..36efa599608a 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 e083eadb57a9..0de74130a3e3 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 594d6a04b11c..10dac12611b5 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 a2070706b9ea..89118307dd69 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 000000000000..56414411c775 --- /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 000000000000..0eeffb13a17e --- /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 d2638fd91821..ff5d53aca761 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 f7659c95873b..6a2829476d6f 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 2b34d58bb051..87f59b2f10f7 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 000000000000..c0829aa02b63 --- /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 57a2f6fe15d6..6d656ea560d1 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 0336bd649e40..64417e6c3dbe 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 9fa699eece1f..abcd34d9d67c 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 77e1030d4b21..3dad3dc4dc0b 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 000000000000..86eed2b8271e --- /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) From 7faa9f7cd3bda68189646b1e9def6cc8aae3b1cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:19:24 -0700 Subject: [PATCH 02/71] fix(native-chat): reasoning rows with a real open/finished state, and readable Claude thinking (#19221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): render structured reasoning as collapsible messages * fix(native-chat): align expanded reasoning with summary * fix(native-chat): place reasoning chevron after summary * De-emphasize reasoning headlines with observed timing labels * Exclude later turn work from observed reasoning duration * fix(native-chat): give reasoning rows a host-owned open/closed lifecycle A reasoning row now says whether its block is still streaming (`state`) and, when the host saw it end, when (`completedAt`). The row's own start is its first-write time, so "Thought for N s" is measured on the execution host instead of by whichever window happened to be watching. Rows open with their first non-empty text and close on every path that ends them: the block's final frame, a new message in the same stream, every Claude turn end through one hook on the open turn, Codex item/completed, turn and session settlement, active-item eviction, and both host sweeps for a dead generation. Closing writes are lifecycle writes so backpressure cannot leave a row open. Rows without the field (older hosts, older journals) never read as live. The reasoning row's headline reads the row's lifecycle and its own turn's liveness: Thinking while open in a running turn, then Thought for N s, Thought when no span was seen, Reasoning when the host kept no lifecycle. * feat(native-chat): ask Claude for readable thinking summaries Under Orca's launch the Claude CLI streams thinking blocks with empty text, so no reasoning row ever had anything to show. Pass only `--thinking-display summarized`: it fills thinking blocks with the API's summaries without turning thinking on, so a user who disabled thinking keeps it off. Orca runs the user's own binary, and a CLI older than the flag exits on it before the session starts. The launch probes the binary it is about to run, overlapping the rest of launch resolution, and passes the flag only when that probe has already answered with 2.1.94 or newer. A slow or failed probe never delays a launch and is never remembered; a successful one is kept per binary until the binary changes. * test(native-chat): type the superseding send in the reasoning lifecycle test * fix(native-chat): stop reading an ended reasoning row as thinking The spinner line infers "Thinking" from the newest root row being a reasoning message. With summaries on, a closed reasoning row stays the newest row while Claude streams a tool's input, so the line read Thinking for the whole Write. A reasoning row now counts only while its own state is running; a row from a host that keeps no state reads as before. * fix(native-chat): measure reasoning from the block's start, not its first text A row opens with its first summary text, which trails the thinking block's start by seconds, and the journal stamps a row with its queued append time. So "Thought for N s" read 1 s for blocks that ran 4.65 s and 12.95 s. The block registry now records the host time at each block's content_block_start (else its first delta), and every write of that block's row carries it as the row's observed time; the close keeps the frame's own receipt time. Codex reasoning rows likewise carry their item/started time. * perf(native-chat): stop rewriting the reasoning row for every thinking token Claude sends a thinking_tokens frame after every thinking delta. Every frame the stream path did not consume forced the streamed text out first, bypassing the checkpoint widening and the coalescing window, so a single thinking block was rewritten and republished once per token: 212 full-row writes and 258 KB for one captured block. A frame that can write no row (token tallies, stream deltas no registry carries, pings) no longer forces that flush; every frame that can write still does. The same block now takes 14 writes. * fix(native-chat): time Codex reasoning by receipt and close evicted rows Codex summary text lands 13-50 ms before item/completed, inside one coalescing window, so a reasoning row's first write can be its completion. Item boundaries are now stamped with their host receipt time the way turn boundaries already were, so a retried or buffered delivery keeps it, and the item translator falls back to the host clock rather than Date.now(). The row carries its item/started time on every first write, including the completion, so its span is item/started to item/completed. An evicted active item is now closed from the text streamed so far, like both settle paths, and the eviction runs before the incoming item is tracked: tracking first let the stream bound drop the evictee's text before the eviction could close its row, stranding it running. completedAt now has one meaning everywhere: the host time the message was seen to end, or the end of the turn or stream that cut it off; absent only when no end was seen live. * refactor(native-chat): keep the Codex streaming body translation pure The streaming translation preserves a reasoning body as-is again; the stream writer, which is what knows the item has not completed, stamps it running. * test(native-chat): keep a re-collapsed reasoning row collapsed through a revision * fix(native-chat): estimate a collapsed reasoning row as its trigger A reasoning row renders collapsed, as one small button, but its height was estimated from its full text: a 4,129-character summary reserved about 950 px for a 24 px row, so long chats jumped as rows were measured. It is now estimated at the trigger's height; opening the row remeasures it. * fix(native-chat): probe the CLI the launch will run, and learn from a refusal The thinking-display gate probed `claude --version` with Orca's own cwd and env, while the launch spawns with the workspace's cwd and the shell env. Behind a version manager's shim those can pick different CLIs, so the probe could approve a CLI the launch never ran, and an older CLI exits on the unknown flag before the session starts. The probe also only counted if it had already finished when resolution did, so a first launch, or the first after a CLI update, usually went without the flag. The probe now runs with the launch's own resolved cwd and env, is keyed by the binary and the workspace, and a launch waits up to 200 ms for it (about 3x the probe's measured p95) before going without the flag. Only answers are kept, so a slow or failed probe is asked again next launch. A child that exits with commander's "unknown option '--thinking-display'" marks that binary in that workspace so the next launch skips the flag; that one start fails exactly as any CLI startup failure does today. * test(native-chat): type the thinking-display probe mock with both of its parameters * fix(native-chat): recheck the account switch after the probe, last as before Moving the invocation ahead of the probe, the transcript check and the permission mode put its account-switch recheck before those awaits, so a switch that began during them launched unchecked. The invocation is the last await again. The probe gets its own env from the same sources the launch uses, the inherited env and the overlay with the CLI's runtime on PATH, built by the same code, minus every credential: asking a CLI its version needs none. * fix(native-chat): wait up to 1.5 s for a cold probe, and remember every outcome 200 ms only covered a warm CLI; a cold disk, a node install or an antivirus scan exceeds it, and that chat's child then ran its whole life without summaries. A launch now waits up to 1.5 s, once per binary per workspace, measured from when that binary's probe began, so a later launch never waits again on a probe already past it. The probe gets its own 10 s kill timeout, and every outcome, including no version printed, a failure or a kill, is kept for the binary's life, so a probe that hangs costs one launch rather than every one. A refusal seen while a probe still runs wins over its late answer. * fix(native-chat): leave Thinking to the activity line while reasoning runs A reasoning row still being written drew a pulsing "Thinking…" header right under the turn's activity line, which already says Thinking: two live indicators for one fact. In a running turn an open reasoning row now draws nothing and reserves no height; it appears when it closes, as "Thought for N s". A row from a host that keeps no state, a closed row, and a row left open by a turn that ended draw as before. The row's Thinking headline is gone with its catalog key. * fix(native-chat): end every unfinished Codex item through one rule A reasoning completion with no text of its own left the row its stream wrote running for good: the completion translated to nothing and the item left the active set, so no settle could reach it. It now closes from the text streamed so far. Settlement, eviction and that completion now build an unfinished item's row through one choice (the streamed text when there is any, else the item as it started) and end it through one rule. An evicted file change with streamed tool output no longer keeps that output as its patch; it reads as interrupted, as a settled one does. A completion whose start was never recorded claims no span, so it reads "Thought". * fix(native-chat): catalogue Claude's stream keep-alive as benign An uncatalogued `ping` stream frame classified as substantive, so the fallback wrote a visible "claude · message:stream_event:ping" row, and since such frames no longer force streamed text out first, a ping inside a coalescing window landed above the open reasoning row. A ping is now benign: it writes no row. * fix(native-chat): bound finding the binary by the probe budget, and keep it LRU Resolving the command's real path and its mtime was awaited before the budgeted wait, so a slow filesystem could hold a launch indefinitely; it now counts against the same budget, and running out caches nothing. The cache is least recently used rather than first written, and holds 32 binary-and-workspace entries rather than 16. * feat(mobile): collapse reasoning rows the way desktop does With summaries on, every Claude turn now carries reasoning text, and the phone drew all of it inline, dimmed, between the prompt and the answer. Mobile now draws a reasoning row as desktop does: collapsed to "Thought for N s", "Thought" or "Reasoning", its text mounted only once opened, and nothing at all while the row is still being written in the live turn or has no text. The headline and the visibility rule live in one shared module both clients read, so they cannot drift. * fix(native-chat): let a failed CLI probe heal instead of latching A probe killed at its timeout, failing to spawn under a loaded boot, or printing no version was cached as "no flag" for the binary's life, so that workspace never got summaries again in that run. Only a version (either side of the floor) or the CLI's own refusal is kept for good now; a probe that gave no version is kept for 10 minutes, so a hung CLI still costs one wait per stretch and a boot-time failure heals. * docs(native-chat): say exactly what the version probe's env leaves out * fix(native-chat): record a Codex item's start whatever its first frame carried The start was recorded only for an item/started that wrote no row, so a reasoning item that started with text lost it and its completion claimed no span. Every tracked item/started now records its receipt time, and the started write carries it too. * fix(mobile): label the reasoning toggle and give it a full touch target The toggle now tells a screen reader what it is, "Reasoning: Thought for 3s", as desktop's prefix does, and reaches a 44 pt target. The shared English copy stays private to the module that formats it. * refactor(native-chat): build reasoning rows from one provider-neutral helper Claude, Codex and the terminal sweeps each built the reasoning row body and its running/ended stamp themselves. They now share journal-reasoning-row: blank text journals no row, text is bounded the same way, and an end carries completedAt only when the host saw it. * refactor(codex): move the active journal item type into the contracts file codex-unfinished-item-body imported the type from the settlement module, which imports values from it. * test(claude): read the launch PATH the way Windows spells it * test(claude): compare the probe's PATH to the launch's without Orca's CLI dir When the CLI's directory also holds node (Linux CI's /usr/local/bin), the runtime pairing puts that directory first, ahead of the Orca CLI directory the launch adds, so the launch PATH no longer ends with the probe's. Both still resolve the same claude and shims. The test now checks that exactly, for a CLI with and without a sibling node. * feat(native-chat): lead the reasoning row with a brain glyph in the tool-row column * fix(native-chat): route the reasoning glyph through the shared icon names, keep its chevron findable, and match it on mobile * fix(mobile): keep the long-press actions sheet on reasoning rows for Android * fix(native-chat): forward every exit argument through the thinking-display connection wrapper * refactor(codex): keep the receipt-timed notification methods with the event they stamp * feat(native-chat): read an open reasoning block through the one live "Thinking" line While the agent's open reasoning block has text, the turn's live activity line is its disclosure: collapsed by default, expandable to the live text (capped and scrollable), and the block's row draws nothing meanwhile. When the block ends, its row appears in place, open if the reader opened it live, because the line and the row read one disclosure key. Which block the line discloses is derived from the line's own render condition, so a row is never hidden while nothing on screen shows it; any other open block (a subagent's, or one a prompt pushed off the line) draws as "Reasoning". Desktop and mobile alike; no host or wire change. * fix(native-chat): a slot kept for its turn bar or diff rollup no longer draws its message The transcript row drew the message of every message slot, while the slot builder pushes a slot for a row it declined to draw whenever that row also carries its turn's bar or diff rollup. So the open reasoning block the live line discloses still drew as a "Reasoning" row when it was a provider-opened turn's first row or the last row of a turn that changed files, and one click opened both. The builder's decision now travels on the slot (`drawsMessage`) and the row draws only the bar and rollup when it is false; the row-level `folded` guard it made redundant is gone. * refactor(native-chat): draw the live line from one shared value, with one live region Desktop and mobile now render the live activity line from one pure function, `nativeChatLiveLine`: whether it draws, what it says, and the open reasoning block it discloses with the text it has so far. The open block's row is hidden from that same value, so desktop no longer restates the line's render condition beside it, and the lines no longer re-derive the text. The line keeps one element, and so one live region, through every state; only its trigger and body come and go, so a screen reader hears "Thinking" and the label after it. On mobile the live text gets the finished row's Android long press (copy or select through the message actions sheet), the line's touch target is the row's 44 pt, and its label and body sit in the finished row's column so nothing moves when the row takes over. * fix(mobile): keep the live text's actions sheet on the block it was opened for On Android the sheet opened by a long press on the live reasoning was a flag gated on a live block: it vanished when the block ended, mid Select text, and the stale flag reopened it unprompted on the next block. The sheet now holds the message it was opened for. * test(native-chat): the reasoning body owns its tone, live and once landed Rendered QA on a pre-merge build showed the live line's open reasoning in full foreground and the landed row's in muted text, so it dimmed as the block landed: the body set no colour of its own and inherited one from wherever it was mounted. Since the main merge (017ad743fa5) the body carries the chat's faint tone itself; this pins that, and that the line and the row draw the same body. --------- Co-authored-by: Merge Sim --- .../MobileNativeChatLiveLine.android.test.ts | 123 +++++++ .../session/MobileNativeChatLiveLine.test.ts | 153 +++++++++ .../src/session/MobileNativeChatLiveLine.tsx | 116 +++++++ .../MobileNativeChatLongPressContent.tsx | 22 ++ .../MobileNativeChatMessage.android.test.ts | 33 ++ .../session/MobileNativeChatMessage.test.ts | 86 +++++ .../src/session/MobileNativeChatMessage.tsx | 73 +++-- .../session/MobileNativeChatReasoningRow.tsx | 102 ++++++ .../MobileNativeChatTurnStatus.test.ts | 40 +-- .../session/MobileNativeChatTurnStatus.tsx | 27 +- .../src/session/MobileNativeChatView.test.ts | 1 + mobile/src/session/MobileNativeChatView.tsx | 20 +- .../MobileNativeChatView.turn-status.test.ts | 9 +- .../MobileNativeChatView.waiting-rows.test.ts | 2 +- .../mobile-native-chat-message-styles.ts | 26 +- ...obile-native-chat-turn-disclosure.test.tsx | 68 ++++ .../use-mobile-native-chat-turn-disclosure.ts | 122 +++++-- src/main/claude/claude-hook-event-versions.ts | 12 +- src/main/claude/claude-message-journaling.ts | 31 +- src/main/claude/claude-open-turn.ts | 6 + .../claude/claude-streamed-block-identity.ts | 48 +-- .../claude-streamed-text-checkpoints.ts | 28 +- src/main/claude/claude-streamed-thinking.ts | 156 +++++++++ ...-journal-translation-context-usage.test.ts | 7 +- ...ude-structured-journal-translation.test.ts | 3 +- .../claude-structured-journal-translation.ts | 45 ++- ...laude-structured-launch-resolution.test.ts | 89 ++++- .../claude-structured-launch-resolution.ts | 144 +++++--- .../claude-structured-provider-fallback.ts | 19 ++ ...ude-structured-reasoning-lifecycle.test.ts | 213 ++++++++++++ .../claude-structured-reasoning.test.ts | 218 +++++++++++++ .../claude-thinking-display-support.test.ts | 168 ++++++++++ .../claude/claude-thinking-display-support.ts | 175 ++++++++++ ...codex-persistent-command-retention.test.ts | 2 + .../codex-structured-item-stream-bounds.ts | 16 + .../codex-structured-item-stream-contracts.ts | 5 +- .../codex/codex-structured-item-streams.ts | 29 +- .../codex-structured-item-translation.test.ts | 28 ++ .../codex-structured-item-translation.ts | 22 +- .../codex-structured-journal-contracts.ts | 13 + .../codex/codex-structured-journal-items.ts | 118 ++++--- .../codex-structured-journal-settlement.ts | 59 +--- .../codex/codex-structured-journal-sink.ts | 3 +- ...red-journal-translation-settlement.test.ts | 3 +- ...red-journal-translation-turn-boundaries.ts | 1 + .../codex-structured-journal-translation.ts | 3 +- ...dex-structured-reasoning-lifecycle.test.ts | 267 +++++++++++++++ .../codex/codex-structured-session-acquire.ts | 7 +- ...ructured-session-adapter-lifecycle.test.ts | 23 ++ .../codex/codex-structured-session-state.ts | 12 +- src/main/codex/codex-unfinished-item-body.ts | 72 ++++ .../journal-reasoning-row.ts | 39 +++ .../journal-terminal-settlement.ts | 11 + .../claude-stream-json-frame-schema.ts | 2 + .../provider-frame-disposition.test.ts | 3 + .../provider-frame-disposition.ts | 1 + ...gent-session-dead-generation-settlement.ts | 8 +- ...ured-agent-session-reasoning-sweep.test.ts | 110 +++++++ ...ude-structured-session-integration.test.ts | 13 + .../runtime/orca-runtime-get-worktree-ps.ts | 3 + .../structured-agent-runtime-registrations.ts | 1 + .../structured-agent-session-runtime.ts | 3 + .../structured-claude-runtime-adapter.ts | 39 ++- ...ed-claude-thinking-display-refusal.test.ts | 97 ++++++ ...iveChatMessageList.live-reasoning.test.tsx | 308 ++++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 59 ++-- .../native-chat/NativeChatMessageRow.test.tsx | 15 +- .../native-chat/NativeChatMessageRow.tsx | 29 +- .../NativeChatReasoningDisclosure.tsx | 45 +++ .../NativeChatReasoningRow.test.tsx | 217 ++++++++++++ .../native-chat/NativeChatReasoningRow.tsx | 77 +++++ .../native-chat/NativeChatToolIcon.tsx | 4 +- .../native-chat/NativeChatTranscriptRow.tsx | 3 +- .../NativeChatTurnActivityLine.tsx | 80 ++++- .../native-chat-row-height-estimate.test.ts | 12 + .../native-chat-row-height-estimate.ts | 9 +- .../native-chat-subagent-section-slots.ts | 1 + .../native-chat-subagent-sections.test.ts | 11 + .../native-chat-transcript-slots.test.ts | 15 + .../native-chat-transcript-slots.ts | 28 +- .../use-native-chat-transcript-slots.ts | 87 +++++ ...ve-chat-transcript-window.options.test.tsx | 1 + src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../agent-session-journal-schemas.test.ts | 22 ++ src/shared/agent-session-journal-schemas.ts | 26 +- ...gent-session-journal-thread-goal-schema.ts | 22 ++ src/shared/agent-session-journal-types.ts | 12 + src/shared/native-chat-live-line.test.ts | 55 ++++ src/shared/native-chat-live-line.ts | 38 +++ src/shared/native-chat-reasoning-row.test.ts | 98 ++++++ src/shared/native-chat-reasoning-row.ts | 98 ++++++ src/shared/native-chat-tool-icon.ts | 2 + src/shared/native-chat-turn-membership.ts | 10 + src/shared/native-chat-types.ts | 5 + ...structured-agent-session-live-turn.test.ts | 14 + .../structured-agent-session-live-turn.ts | 4 +- .../structured-agent-session-projection.ts | 18 + ...agent-session-reasoning-projection.test.ts | 55 ++++ 103 files changed, 4418 insertions(+), 478 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatLiveLine.android.test.ts create mode 100644 mobile/src/session/MobileNativeChatLiveLine.test.ts create mode 100644 mobile/src/session/MobileNativeChatLiveLine.tsx create mode 100644 mobile/src/session/MobileNativeChatLongPressContent.tsx create mode 100644 mobile/src/session/MobileNativeChatReasoningRow.tsx create mode 100644 src/main/claude/claude-streamed-thinking.ts create mode 100644 src/main/claude/claude-structured-reasoning-lifecycle.test.ts create mode 100644 src/main/claude/claude-structured-reasoning.test.ts create mode 100644 src/main/claude/claude-thinking-display-support.test.ts create mode 100644 src/main/claude/claude-thinking-display-support.ts create mode 100644 src/main/codex/codex-structured-reasoning-lifecycle.test.ts create mode 100644 src/main/codex/codex-unfinished-item-body.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-reasoning-row.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts create mode 100644 src/main/runtime/structured-claude-thinking-display-refusal.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatReasoningDisclosure.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts create mode 100644 src/shared/agent-session-journal-thread-goal-schema.ts create mode 100644 src/shared/native-chat-live-line.test.ts create mode 100644 src/shared/native-chat-live-line.ts create mode 100644 src/shared/native-chat-reasoning-row.test.ts create mode 100644 src/shared/native-chat-reasoning-row.ts create mode 100644 src/shared/structured-agent-session-reasoning-projection.test.ts diff --git a/mobile/src/session/MobileNativeChatLiveLine.android.test.ts b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts new file mode 100644 index 000000000000..daecbe6ab622 --- /dev/null +++ b/mobile/src/session/MobileNativeChatLiveLine.android.test.ts @@ -0,0 +1,123 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveReasoning } from '../../../src/shared/native-chat-reasoning-row' + +// Why a separate file: Android has no inline selection, so the live text is copied the way a +// finished row's is, through the message actions sheet. +vi.mock('react-native', async () => { + const React = await import('react') + const host = + (name: string) => + ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement(name, props, children) + return { + ActivityIndicator: host('ActivityIndicator'), + Platform: { OS: 'android' }, + Pressable: host('Pressable'), + Text: host('Text'), + View: host('View'), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) +vi.mock('./MobileNativeChatReasoningRow', () => ({ + MobileNativeChatReasoningBody: 'ReasoningBody' +})) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' + +const block: NativeChatLiveReasoning = { + message: { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: null, + source: 'transcript', + state: 'running' + }, + markdown: 'Weighing two approaches' +} + +describe('MobileNativeChatLiveLine on Android', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + const byType = (type: string): ReactTestInstance[] => + renderer!.root.findAll((node) => String(node.type) === type) + + it('opens the actions sheet for the live block from a long press on its text', () => { + act(() => { + renderer = create( + createElement(MobileNativeChatLiveLine, { + line: { thinking: true, activityText: null, reasoning: block, reasoningExpanded: true }, + onToggleReasoning: vi.fn(), + fontScale: 1 + }) + ) + }) + expect(byType('MessageActionsSheet')).toHaveLength(0) + const [body] = byType('ReasoningBody') + expect(typeof body?.props.onLongPress).toBe('function') + + act(() => body!.props.onLongPress()) + const [sheet] = byType('MessageActionsSheet') + // The sheet copies and selects the message's own text: the block as it stands. + expect(sheet?.props.message).toBe(block.message) + + act(() => sheet!.props.onClose()) + expect(byType('MessageActionsSheet')).toHaveLength(0) + }) + + it('keeps the sheet through the block ending, and never opens one for the next by itself', () => { + const line = (reasoning: NativeChatLiveReasoning | null) => + createElement(MobileNativeChatLiveLine, { + line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + onToggleReasoning: vi.fn(), + fontScale: 1 + }) + act(() => { + renderer = create(line(block)) + }) + act(() => byType('ReasoningBody')[0]!.props.onLongPress()) + // The block ends while the reader copies or selects it. + act(() => renderer!.update(line(null))) + expect(byType('MessageActionsSheet').map((sheet) => sheet.props.message)).toEqual([ + block.message + ]) + act(() => byType('MessageActionsSheet')[0]!.props.onClose()) + const next: NativeChatLiveReasoning = { + message: { ...block.message, id: 'r-2' }, + markdown: 'Next thought' + } + act(() => renderer!.update(line(next))) + expect(byType('MessageActionsSheet')).toHaveLength(0) + }) + + it('opens no sheet for the next block when the first ends with the sheet open', () => { + const line = (reasoning: NativeChatLiveReasoning | null) => + createElement(MobileNativeChatLiveLine, { + line: { thinking: true, activityText: null, reasoning, reasoningExpanded: true }, + onToggleReasoning: vi.fn(), + fontScale: 1 + }) + act(() => { + renderer = create(line(block)) + }) + act(() => byType('ReasoningBody')[0]!.props.onLongPress()) + act(() => renderer!.update(line(null))) + const next: NativeChatLiveReasoning = { + message: { ...block.message, id: 'r-2' }, + markdown: 'Next thought' + } + act(() => renderer!.update(line(next))) + // Still the first block's sheet, the one the reader opened; none for r-2. + expect(byType('MessageActionsSheet').map((sheet) => sheet.props.message.id)).toEqual(['r-1']) + }) +}) diff --git a/mobile/src/session/MobileNativeChatLiveLine.test.ts b/mobile/src/session/MobileNativeChatLiveLine.test.ts new file mode 100644 index 000000000000..37bcc460ca5f --- /dev/null +++ b/mobile/src/session/MobileNativeChatLiveLine.test.ts @@ -0,0 +1,153 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveReasoning } from '../../../src/shared/native-chat-reasoning-row' + +const pressableMounts = vi.hoisted(() => ({ count: 0 })) + +vi.mock('react-native', async () => { + const React = await import('react') + const host = + (name: string) => + ({ children, ...props }: { children?: ReactNode }): ReactNode => + React.createElement(name, props, children) + // Counts mounts, so a test can tell the live region was kept rather than replaced. + const Pressable = ({ children, ...props }: { children?: ReactNode }): ReactNode => { + React.useEffect(() => { + pressableMounts.count += 1 + }, []) + return React.createElement('Pressable', props, children) + } + return { + ActivityIndicator: host('ActivityIndicator'), + Platform: { OS: 'ios' }, + Pressable, + Text: host('Text'), + View: host('View'), + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 } + } +}) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) +vi.mock('./MobileNativeChatReasoningRow', () => ({ + MobileNativeChatReasoningBody: 'ReasoningBody' +})) +vi.mock('./MobileNativeChatMessageActionsSheet', () => ({ + MobileNativeChatMessageActionsSheet: 'MessageActionsSheet' +})) + +import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' + +const block: NativeChatLiveReasoning = { + message: { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: null, + source: 'transcript', + state: 'running' + }, + markdown: 'Weighing two approaches' +} + +describe('MobileNativeChatLiveLine', () => { + let renderer: ReactTestRenderer | null = null + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + pressableMounts.count = 0 + }) + + const onToggleReasoning = vi.fn() + function element( + fields: { + thinking?: boolean + activityText?: string | null + reasoning?: NativeChatLiveReasoning | null + reasoningExpanded?: boolean + } = {} + ) { + return createElement(MobileNativeChatLiveLine, { + line: { + thinking: true, + activityText: null, + reasoning: null, + reasoningExpanded: false, + ...fields + }, + onToggleReasoning, + fontScale: 1 + }) + } + function render(fields: Parameters[0] = {}): ReactTestInstance { + act(() => { + renderer = create(element(fields)) + }) + return renderer!.root + } + const byType = (root: ReactTestInstance, type: string): ReactTestInstance[] => + root.findAll((node) => String(node.type) === type) + const labels = (root: ReactTestInstance): string[] => + byType(root, 'Text').map((text) => String(text.children.join(''))) + const header = (root: ReactTestInstance): ReactTestInstance => + root.find((node) => String(node.type) === 'Pressable') + + it('reads "Thinking" beside one spinner while the turn reasons', () => { + const root = render() + expect(labels(root)).toEqual(['Thinking']) + expect(byType(root, 'ActivityIndicator')).toHaveLength(1) + }) + + // The bar owns the clock; the tail line never repeats it. + it('reads plain "Working…" when the turn is not reasoning, and holds no timer', () => { + vi.useFakeTimers() + const root = render({ thinking: false }) + expect(labels(root)).toEqual(['Working…']) + expect(vi.getTimerCount()).toBe(0) + vi.useRealTimers() + }) + + it('lets provider activity text beat both fallbacks', () => { + expect(labels(render({ activityText: 'Running pnpm test' }))).toEqual(['Running pnpm test']) + }) + + it('announces what it says to assistive tech, and is no button while it discloses nothing', () => { + const line = header(render()) + expect(line.props.accessibilityLiveRegion).toBe('polite') + expect(line.props.accessibilityLabel).toBe('Thinking') + expect(line.props.accessibilityRole).toBeUndefined() + expect(line.props.onPress).toBeUndefined() + }) + + it('discloses the open block under one "Thinking", collapsed, toggled by its block key', () => { + const root = render({ reasoning: block }) + expect(labels(root)).toEqual(['Thinking']) + const line = header(root) + expect(line.props.accessibilityRole).toBe('button') + expect(line.props.accessibilityState).toEqual({ expanded: false }) + expect(byType(root, 'ReasoningBody')).toHaveLength(0) + act(() => line.props.onPress()) + expect(onToggleReasoning).toHaveBeenCalledWith('reasoning:r-1') + }) + + it('shows the live text outside the live region once opened', () => { + const root = render({ reasoning: block, reasoningExpanded: true }) + const [body] = byType(root, 'ReasoningBody') + expect(body?.props.markdown).toBe('Weighing two approaches') + let ancestor = body?.parent ?? null + while (ancestor) { + expect(ancestor.props.accessibilityLiveRegion).toBeUndefined() + ancestor = ancestor.parent + } + // iOS selects inline, so the body takes no long press. + expect(body?.props.onLongPress).toBeUndefined() + }) + + it('keeps one live region while it turns into the disclosure and back', () => { + render({ thinking: false }) + act(() => renderer!.update(element({ reasoning: block }))) + expect(header(renderer!.root).props.accessibilityLabel).toBe('Thinking') + act(() => renderer!.update(element({ thinking: false }))) + expect(header(renderer!.root).props.accessibilityLabel).toBe('Working…') + expect(pressableMounts.count).toBe(1) + }) +}) diff --git a/mobile/src/session/MobileNativeChatLiveLine.tsx b/mobile/src/session/MobileNativeChatLiveLine.tsx new file mode 100644 index 000000000000..30ead56325c7 --- /dev/null +++ b/mobile/src/session/MobileNativeChatLiveLine.tsx @@ -0,0 +1,116 @@ +import { useCallback, useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { ChevronRight } from 'lucide-react-native' +import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' +import { formatNativeChatActiveTurnLabel } from '../../../src/shared/native-chat-turn-status' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' +import { colors, spacing, typography } from '../theme/mobile-theme' +import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' +import { MobileNativeChatReasoningBody } from './MobileNativeChatReasoningRow' +import type { MobileNativeChatLiveLine as LiveLine } from './use-mobile-native-chat-turn-disclosure' + +/** The live turn's tail line: a spinner beside what the provider says it is doing, else + * "Thinking", else "Working…". The clock stays in the turn bar. While the agent's open reasoning + * block has text it is also that block's disclosure, and the block's row draws nothing. + * Desktop parity: `NativeChatTurnActivityLine`. */ +export function MobileNativeChatLiveLine({ + line, + onToggleReasoning, + fontScale, + onOpenFile +}: { + line: LiveLine + onToggleReasoning: (key: string) => void + fontScale: number + onOpenFile?: (relativePath: string) => void +}): React.JSX.Element { + const { reasoning, reasoningExpanded } = line + const open = reasoning !== null && reasoningExpanded + const label = formatNativeChatActiveTurnLabel(line) + // Android has no inline selection; the finished row's long-press sheet copies the live text too. + // It holds the block it opened for, so it outlives that block ending and never reopens by itself. + const [actionsFor, setActionsFor] = useState(null) + const liveMessage = reasoning?.message ?? null + const openActions = useCallback(() => setActionsFor(liveMessage), [liveMessage]) + return ( + + {/* One element for every state of the line, so TalkBack hears each new label; the body + sits outside it. */} + [styles.row, reasoning && pressed && styles.pressed]} + onPress={ + reasoning + ? () => onToggleReasoning(nativeChatReasoningDisclosureKey(reasoning.message.id)) + : undefined + } + // With the row's 32 pt height, the 44 pt target the reasoning row has. + hitSlop={6} + accessibilityRole={reasoning ? 'button' : undefined} + accessibilityState={reasoning ? { expanded: open } : undefined} + accessibilityLabel={label} + accessibilityLiveRegion="polite" + > + + + + + {label} + + {reasoning ? ( + + + + ) : null} + + {reasoning && open ? ( + + + + ) : null} + {actionsFor ? ( + setActionsFor(null)} + /> + ) : null} + + ) +} + +// The finished reasoning row's geometry (row gutter, 15 pt glyph slot, its gap, its 32 pt height), +// so the label and the open text do not move when that row takes over. +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + minHeight: 32, + paddingHorizontal: spacing.lg + }, + // The spinner is wider than the brain; centred in the brain's slot it keeps the text column. + glyph: { + width: 15, + alignItems: 'center', + overflow: 'visible' + }, + pressed: { + opacity: 0.6 + }, + label: { + color: colors.textMuted, + fontSize: typography.bodySize, + flexShrink: 1 + }, + caretOpen: { + transform: [{ rotate: '90deg' }] + }, + body: { + paddingHorizontal: spacing.lg + } +}) diff --git a/mobile/src/session/MobileNativeChatLongPressContent.tsx b/mobile/src/session/MobileNativeChatLongPressContent.tsx new file mode 100644 index 000000000000..923e949b7b4b --- /dev/null +++ b/mobile/src/session/MobileNativeChatLongPressContent.tsx @@ -0,0 +1,22 @@ +import type { ComponentProps, ReactNode } from 'react' +import { Pressable, View } from 'react-native' + +/** A message body that opens the actions sheet on long press (Android, which has no inline + * selection). Keep the existing responder hierarchy on platforms with inline selection. */ +export function MobileNativeChatLongPressContent({ + onLongPress, + style, + children +}: { + onLongPress?: () => void + style: ComponentProps['style'] + children: ReactNode +}): React.JSX.Element { + return onLongPress ? ( + + {children} + + ) : ( + {children} + ) +} diff --git a/mobile/src/session/MobileNativeChatMessage.android.test.ts b/mobile/src/session/MobileNativeChatMessage.android.test.ts index fff2a8d9eba3..104aa35d2a09 100644 --- a/mobile/src/session/MobileNativeChatMessage.android.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.android.test.ts @@ -23,6 +23,8 @@ vi.mock('react-native', async () => { Image: 'Image', Platform: { OS: 'android' }, Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: ReactNode }) => + React.createElement('ScrollView', props, children), Text, View: ({ children, ...props }: { children?: ReactNode }) => React.createElement('View', props, children), @@ -32,6 +34,7 @@ vi.mock('react-native', async () => { vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Brain: 'Brain', ChevronDown: 'ChevronDown', Copy: 'Copy', SquareChevronRight: 'SquareChevronRight', @@ -106,6 +109,36 @@ describe('MobileNativeChatMessage on Android', () => { expect(byType('MessageActionsSheet')).toHaveLength(1) }) + it('opens the actions sheet from a long press on an expanded reasoning row, not on its headline', () => { + const reasoning: NativeChatMessage = { + ...message, + id: 'r1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: 1_000, + state: 'completed', + completedAt: 4_000 + } + act(() => { + renderer = create(createElement(MobileNativeChatMessage, { message: reasoning })) + }) + const [toggle] = byType('Pressable') + // A long press on the headline stays a plain toggle tap. + expect(toggle!.props.onLongPress).toBeUndefined() + act(() => toggle!.props.onPress()) + + const body = byType('Pressable').find((node) => node.props.onLongPress !== undefined) + expect(typeof body?.props.onLongPress).toBe('function') + const [markdown] = byType('MobileMarkdown') + expect(markdown!.props.onLongPress).toBe(body!.props.onLongPress) + + act(() => body!.props.onLongPress()) + const [sheet] = byType('MessageActionsSheet') + expect(sheet!.props.message).toBe(reasoning) + act(() => sheet!.props.onClose()) + expect(byType('MessageActionsSheet')).toHaveLength(0) + }) + it('renders the user bubble without inline selection', () => { act(() => { renderer = create( diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 91248061048e..2a2489bbdbc2 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -26,6 +26,8 @@ vi.mock('react-native', async () => { Image: 'Image', Platform: { OS: 'ios' }, Pressable: 'Pressable', + ScrollView: ({ children, ...props }: { children?: unknown }) => + React.createElement('ScrollView', props, children), Text, View: ({ children, ...props }: { children?: unknown }) => React.createElement('View', props, children), @@ -35,6 +37,7 @@ vi.mock('react-native', async () => { vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() })) vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', + Brain: 'Brain', ChevronDown: 'ChevronDown', Copy: 'Copy', SquareChevronRight: 'SquareChevronRight', @@ -79,6 +82,7 @@ describe('MobileNativeChatMessage', () => { workedSeconds: number | null } | null onToggleTurn?: () => void + reasoningIsLive?: boolean } = {} ): ReactTestRenderer { act(() => { @@ -375,4 +379,86 @@ describe('MobileNativeChatMessage', () => { expect(textIn(tree.root)).toEqual(['go']) }) }) + + describe('a reasoning row', () => { + const reasoning = (fields: Partial = {}): NativeChatMessage => ({ + id: 'r1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: 1_000, + source: 'transcript', + state: 'completed', + completedAt: 4_000, + ...fields + }) + const toggleOf = (tree: ReactTestRenderer): ReactTestInstance => + tree.root.find( + (node) => String(node.type) === 'Pressable' && node.props.accessibilityRole === 'button' + ) + const markdownIn = (tree: ReactTestRenderer): ReactTestInstance[] => + tree.root.findAll((node) => String(node.type) === 'MobileMarkdown') + + it('starts collapsed to its headline, with its text unmounted', () => { + const tree = render(reasoning()) + expect(textIn(tree.root)).toContain('Thought for 3s') + expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: false }) + // Said with what it is, as desktop's screen-reader prefix does, on a 32 + 2 × 6 pt target. + expect(toggleOf(tree).props.accessibilityLabel).toBe('Reasoning: Thought for 3s') + expect(toggleOf(tree).props.hitSlop).toBe(6) + expect(markdownIn(tree)).toHaveLength(0) + }) + + it('leads its headline with the brain, as desktop does', () => { + const [first] = toggleOf(render(reasoning())).children + expect(typeof first === 'string' ? first : first?.type).toBe('Brain') + }) + + it('mounts its text once opened', () => { + const tree = render(reasoning()) + act(() => toggleOf(tree).props.onPress()) + expect(toggleOf(tree).props.accessibilityState).toEqual({ expanded: true }) + expect(markdownIn(tree).map((node) => node.props.content)).toEqual([ + 'Weighing two approaches' + ]) + }) + + it('draws nothing while the live line discloses it, or when blank', () => { + expect( + render(reasoning({ state: 'running' }), { + activeTurnIsWorking: true, + reasoningIsLive: true + }).toJSON() + ).toBeNull() + expect(render(reasoning({ blocks: [{ type: 'text', text: ' \n ' }] })).toJSON()).toBeNull() + }) + + // The turn's bar is not the block's: hiding the block must not hide the bar it sits on. + it("still draws its turn's bar while the live line discloses it", () => { + const tree = render(reasoning({ state: 'running' }), { + activeTurnIsWorking: true, + reasoningIsLive: true, + turnStatus: { startedAt: 1_000, thinking: true, workedSeconds: null } + }) + expect(tree.root.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0) + expect(textIn(tree.root).some((text) => text.startsWith('Working for'))).toBe(true) + }) + + // Only the block the line discloses hides: a subagent's or a stale open block draws, unended. + it('draws any other open block in its working turn as Reasoning', () => { + const child = render(reasoning({ state: 'running', agentId: 'sub-1' }), { + activeTurnIsWorking: true + }) + expect(textIn(child.root)).toContain('Reasoning') + expect(toggleOf(child).props.accessibilityLabel).toBe('Reasoning') + }) + + it('says only what the host saw', () => { + expect(textIn(render(reasoning({ state: 'running' })).root)).toContain('Thought') + const unknown = render(reasoning({ state: undefined, completedAt: undefined })) + expect(textIn(unknown.root)).toContain('Reasoning') + // No "Reasoning: Reasoning". + expect(toggleOf(unknown).props.accessibilityLabel).toBe('Reasoning') + expect(textIn(render(reasoning({ completedAt: 1_300 })).root)).toContain('Thought for 1s') + }) + }) }) diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 83f93266d6ff..ec0a6d48b310 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -1,8 +1,9 @@ import { MobileSelectableText as Text } from '../components/MobileSelectableText' -import { memo, useCallback, useState, type ComponentProps, type ReactNode } from 'react' -import { Image, Text as NativeText, Pressable, View } from 'react-native' +import { memo, useCallback, useState } from 'react' +import { Image, Text as NativeText, View } from 'react-native' import { INLINE_TEXT_SELECTION } from '../components/inline-text-selection' import { MobileNativeChatMessageActionsSheet } from './MobileNativeChatMessageActionsSheet' +import { MobileNativeChatLongPressContent as Content } from './MobileNativeChatLongPressContent' import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold' import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity' import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types' @@ -12,6 +13,8 @@ import { } from '../../../src/shared/agent-session-host-status-rows' import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types' import { MobileMarkdown } from '../components/MobileMarkdown' +import { deriveNativeChatRowContent } from '../../../src/shared/native-chat-row-content' +import { MobileNativeChatReasoningRow } from './MobileNativeChatReasoningRow' import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' import { ToolRun } from './MobileNativeChatToolRun' import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' @@ -88,25 +91,6 @@ function Prose({ return null } -// Keep the existing responder hierarchy on platforms with inline selection. -function Content({ - onLongPress, - style, - children -}: { - onLongPress?: () => void - style: ComponentProps['style'] - children: ReactNode -}): React.JSX.Element { - return onLongPress ? ( - - {children} - - ) : ( - {children} - ) -} - function MobileNativeChatMessageImpl({ message, toolsExpanded = false, @@ -118,7 +102,10 @@ function MobileNativeChatMessageImpl({ turnKey, onToggleTurn, activeTurnIsWorking, - structuredActivityUi = false + structuredActivityUi = false, + reasoningIsLive = false, + reasoningExpanded, + onToggleReasoning }: { message: NativeChatMessage toolsExpanded?: boolean @@ -139,6 +126,11 @@ function MobileNativeChatMessageImpl({ activeTurnIsWorking?: boolean /** Structured lane only: live tool progress plus the turn-status disclosure. */ structuredActivityUi?: boolean + /** This open reasoning block is disclosed by the live activity line, so its row draws nothing. */ + reasoningIsLive?: boolean + /** The transcript-held disclosure of a reasoning row; one stable handler takes its key. */ + reasoningExpanded?: boolean + onToggleReasoning?: (key: string) => void }): React.JSX.Element { const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' @@ -175,15 +167,44 @@ function MobileNativeChatMessageImpl({ onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined} /> ) : null + if (isReasoning) { + // The same text the live line's selector reads, so the two agree on whether there is any. + const markdown = deriveNativeChatRowContent(message.blocks).markdown + // Blank, or disclosed by the live activity line: nothing draws, not even an empty row. + const draws = markdown.trim().length > 0 && !reasoningIsLive + return ( + <> + {turnStatusAbove ? statusRow : null} + {draws ? ( + + + + ) : null} + {actionsOpen ? ( + setActionsOpen(false)} + /> + ) : null} + {turnStatusAbove ? null : statusRow} + + ) + } return ( <> {/* A turn with no user bubble carries its bar above its first row. */} {turnStatusAbove ? statusRow : null} - + {prose.map((block, index) => ( + markdown: string + fontScale: number + /** Drawn inside its working turn: an open row there has not ended. */ + live?: boolean + /** The transcript's disclosure, keyed like the live line's; absent, the row keeps its own. */ + expanded?: boolean + onToggle?: (key: string) => void + onOpenFile?: (relativePath: string) => void + /** Android only: opens the message's actions sheet, as a long press on any other message does. */ + onLongPress?: () => void +}): React.JSX.Element { + const [localExpanded, setLocalExpanded] = useState(false) + const expanded = onToggle ? transcriptExpanded : localExpanded + const toggle = () => + onToggle ? onToggle(nativeChatReasoningDisclosureKey(message.id)) : setLocalExpanded(!expanded) + const headline = nativeChatReasoningHeadlineText(nativeChatReasoningHeadline(message, { live })) + const label = nativeChatReasoningHeadlineText({ kind: 'reasoning' }) + return ( + + [styles.reasoningToggle, pressed && styles.reasoningPressed]} + onPress={toggle} + hitSlop={6} + accessibilityRole="button" + accessibilityState={{ expanded }} + // Desktop's screen-reader prefix: the headline alone does not say what was thought. + accessibilityLabel={headline === label ? label : `${label}: ${headline}`} + > + + + {headline} + + + + + + {expanded ? ( + + ) : null} + + ) +} + +/** A reasoning block's text, under its row or the live activity line. Capped and scrollable, so an + * open block streaming at the tail cannot grow without bound. */ +export function MobileNativeChatReasoningBody({ + markdown, + fontScale, + onOpenFile, + onLongPress +}: { + markdown: string + fontScale: number + onOpenFile?: (relativePath: string) => void + onLongPress?: () => void +}): React.JSX.Element { + return ( + + + + + + ) +} diff --git a/mobile/src/session/MobileNativeChatTurnStatus.test.ts b/mobile/src/session/MobileNativeChatTurnStatus.test.ts index a0393996cec3..2506ab6e311c 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.test.ts +++ b/mobile/src/session/MobileNativeChatTurnStatus.test.ts @@ -19,10 +19,7 @@ vi.mock('react-native', async () => { }) vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' })) -import { - MobileNativeChatTurnActivity, - MobileNativeChatTurnStatus -} from './MobileNativeChatTurnStatus' +import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus' const labels = (node: ReactTestInstance): string[] => node.findAllByType('Text' as never).map((text) => String(text.children.join(''))) @@ -108,38 +105,3 @@ describe('MobileNativeChatTurnStatus', () => { expect(vi.getTimerCount()).toBe(0) }) }) - -describe('MobileNativeChatTurnActivity', () => { - function render(props: { thinking: boolean; activityText?: string | null }): ReactTestRenderer { - act(() => { - renderer = create(createElement(MobileNativeChatTurnActivity, props)) - }) - return renderer! - } - - it('reads "Thinking" beside one spinner while the turn reasons', () => { - const tree = render({ thinking: true }) - expect(labels(tree.root)).toEqual(['Thinking']) - expect(spinners(tree.root)).toHaveLength(1) - }) - - // The bar owns the clock; the tail line never repeats it. - it('reads plain "Working…" when the turn is not reasoning, and holds no timer', () => { - const tree = render({ thinking: false }) - expect(labels(tree.root)).toEqual(['Working…']) - expect(vi.getTimerCount()).toBe(0) - }) - - it('lets provider activity text beat both fallbacks', () => { - const tree = render({ thinking: true, activityText: 'Running pnpm test' }) - expect(labels(tree.root)).toEqual(['Running pnpm test']) - expect(spinners(tree.root)).toHaveLength(1) - }) - - it('announces the live line to assistive tech', () => { - const tree = render({ thinking: true }) - const row = tree.root.findByType('View' as never) - expect(row.props.accessibilityLiveRegion).toBe('polite') - expect(row.props.accessibilityLabel).toBe('Agent is responding') - }) -}) diff --git a/mobile/src/session/MobileNativeChatTurnStatus.tsx b/mobile/src/session/MobileNativeChatTurnStatus.tsx index a39e404c6397..9bb6a5c9e0fa 100644 --- a/mobile/src/session/MobileNativeChatTurnStatus.tsx +++ b/mobile/src/session/MobileNativeChatTurnStatus.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react' -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { ChevronRight } from 'lucide-react-native' import { - formatNativeChatActiveTurnLabel, formatNativeChatTurnStatusLabel, NATIVE_CHAT_TURN_STATUS_COPY, nativeChatElapsedSeconds @@ -76,30 +75,6 @@ export function MobileNativeChatTurnStatus({ ) } -/** The live turn's tail line: a spinner beside what the provider says it is doing, - * else "Thinking", else "Working…". The clock stays in the turn bar. Desktop - * parity: `NativeChatTurnActivityLine`. */ -export function MobileNativeChatTurnActivity({ - thinking, - activityText -}: { - thinking: boolean - activityText?: string | null -}): React.JSX.Element { - return ( - - - - {formatNativeChatActiveTurnLabel({ activityText, thinking })} - - - ) -} - const styles = StyleSheet.create({ row: { flexDirection: 'row', diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index ff2637f8a456..d96c9179b08b 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -47,6 +47,7 @@ vi.mock('lucide-react-native', () => ({ })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 8e729e5e46b3..a559bd02ef25 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -30,7 +30,7 @@ import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch- import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' -import { MobileNativeChatTurnActivity } from './MobileNativeChatTurnStatus' +import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { MobileNativeChatComposer } from './MobileNativeChatComposer' @@ -278,10 +278,9 @@ export function MobileNativeChatView({ turnJournal, thinking: turnIndicator?.thinking === true, activityText: turnIndicator?.activityText ?? null, + lineYields: structuredActivityUi && (ask != null || permission != null || question != null), scopeKey: sendSurfaceId }) - const hasPendingStructuredInteraction = - structuredActivityUi && (ask != null || permission != null || question != null) const renderItem = useCallback( ({ item, index }: { item: NativeChatMessage; index: number }) => ( @@ -298,13 +297,14 @@ export function MobileNativeChatView({ [toolsExpanded, fontScale, onOpenFile, structuredActivityUi, turns] ) - const liveStatus = - structuredActivityUi && agentWorking && !hasPendingStructuredInteraction && turns.active ? ( - - ) : null + const liveStatus = turns.liveLine ? ( + + ) : null const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 diff --git a/mobile/src/session/MobileNativeChatView.turn-status.test.ts b/mobile/src/session/MobileNativeChatView.turn-status.test.ts index 1f8191df80b8..47137a7deaa0 100644 --- a/mobile/src/session/MobileNativeChatView.turn-status.test.ts +++ b/mobile/src/session/MobileNativeChatView.turn-status.test.ts @@ -47,6 +47,7 @@ vi.mock('lucide-react-native', () => ({ })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) @@ -177,13 +178,11 @@ describe('MobileNativeChatView', () => { return (renderedRow(id) as { props: Record }).props } + /** What the live footer line says: its label inputs, read off the line the view hands it. */ function footerProps(): Record | null { const list = renderer!.root.find((node) => String(node.type) === 'FlatList') - const footer = list.props.ListFooterComponent as - | { props: Record } - | null - | undefined - return footer?.props ?? null + const line = list.props.ListFooterComponent?.props.line + return line ? { thinking: line.thinking, activityText: line.activityText } : null } function workingIndicators(): ReactTestInstance[] { diff --git a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts index 518d85f2465e..f4916c0a324f 100644 --- a/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts +++ b/mobile/src/session/MobileNativeChatView.waiting-rows.test.ts @@ -37,7 +37,7 @@ vi.mock('lucide-react-native', () => ({ Square: 'Square' })) vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) -vi.mock('./MobileNativeChatTurnStatus', () => ({ MobileNativeChatTurnActivity: 'LiveStatus' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) vi.mock('./MobileNativeChatComposer', () => ({ MobileNativeChatComposer: 'Composer' })) // The queue's action sheet pulls in the animation runtime, which this react-native mock can't host. vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 225d664e65e4..2acad402176c 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -35,7 +35,31 @@ export const styles = StyleSheet.create({ lineHeight: TEXT_SIZE + 6 }, reasoning: { - opacity: 0.7 + opacity: 0.7, + // Starts under the headline, past the 15 pt brain and its gap. + paddingLeft: 15 + spacing.sm + }, + reasoningToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + // With the toggle's 6 pt hitSlop above and below, a 44 pt touch target. + minHeight: 32 + }, + reasoningPressed: { + opacity: 0.6 + }, + reasoningHeadline: { + color: colors.textMuted, + fontSize: typography.bodySize, + flexShrink: 1 + }, + reasoningCaretOpen: { + transform: [{ rotate: '90deg' }] + }, + reasoningBody: { + // The common cap for an open reasoning block (about ten lines). + maxHeight: 240 }, toolRun: { marginTop: spacing.xs diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index 19f0dfbc0dbe..5a29875b08c5 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -29,6 +29,8 @@ function Harness({ settledTurns, turnJournal, workingStartedAt, + thinking, + lineYields, scopeKey = 'host\0worktree\0tab-a' }: { messages: readonly NativeChatMessage[] @@ -37,6 +39,8 @@ function Harness({ settledTurns?: NativeChatSettledTurns turnJournal?: NativeChatTurnJournal workingStartedAt?: number | null + thinking?: boolean + lineYields?: boolean scopeKey?: string }): React.JSX.Element { const disclosure = useMobileNativeChatTurnDisclosure({ @@ -46,6 +50,8 @@ function Harness({ settledTurns, turnJournal, workingStartedAt, + thinking, + lineYields, scopeKey }) return createElement('result', { disclosure }) @@ -762,3 +768,65 @@ describe('useMobileNativeChatTurnDisclosure', () => { expect(rows[0].turnStatus?.workedSeconds).toBe(4) }) }) + +describe('the open reasoning block the live line discloses', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + const block = (id: string, state: 'running' | 'completed'): NativeChatMessage => ({ + id, + role: 'reasoning', + blocks: [{ type: 'text', text: `${id} weighs two approaches` }], + timestamp: null, + source: 'transcript', + state + }) + const show = ( + messages: NativeChatMessage[], + props: { thinking?: boolean; lineYields?: boolean } + ) => + act(() => { + const element = createElement(Harness, { messages, enabled: true, ...props }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + const latest = () => renderer!.root.findByType('result').props.disclosure + + it('hides only that block, and lands it open once it ends if the reader opened it live', () => { + const prompt = userMessage('u1') + show([prompt, block('r-1', 'running')], { thinking: true }) + expect(latest().liveLine).toMatchObject({ + reasoning: { message: { id: 'r-1' } }, + reasoningExpanded: false + }) + expect(latest().resolveRow(1, block('r-1', 'running')).reasoningIsLive).toBe(true) + act(() => latest().onToggleReasoning('reasoning:r-1')) + expect(latest().liveLine.reasoningExpanded).toBe(true) + + // Closed while the turn works on: the line discloses nothing, and the row draws open. + show([prompt, block('r-1', 'completed')], { thinking: false }) + expect(latest().liveLine).toMatchObject({ reasoning: null }) + const row = latest().resolveRow(1, block('r-1', 'completed')) + expect(row).toMatchObject({ reasoningIsLive: false, reasoningExpanded: true }) + + // The next block starts collapsed. + show([prompt, block('r-1', 'completed'), block('r-2', 'running')], { thinking: true }) + expect(latest().liveLine).toMatchObject({ + reasoning: { message: { id: 'r-2' } }, + reasoningExpanded: false + }) + }) + + it('discloses nothing, and hides nothing, while a prompt takes the line', () => { + show([userMessage('u1'), block('r-1', 'running')], { thinking: true, lineYields: true }) + expect(latest().liveLine).toBeNull() + expect(latest().resolveRow(1, block('r-1', 'running')).reasoningIsLive).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts index 6e1d4df395e9..4c7262efc705 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.ts @@ -1,7 +1,13 @@ import { useCallback, useMemo, useState } from 'react' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { nativeChatReasoningDisclosureKey } from '../../../src/shared/native-chat-reasoning-row' +import { + nativeChatLiveLine, + type NativeChatLiveLine +} from '../../../src/shared/native-chat-live-line' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' import { + isNativeChatRowInLiveWorkingTurn, nativeChatMessagesWaitingBehindLiveTurn, nativeChatTurnMembership, type NativeChatTurnJournal @@ -27,6 +33,41 @@ export type MobileNativeChatTurnRow = { /** Set only on a settled turn — the one row that has activity to disclose. */ turnKey?: string activeTurnIsWorking: boolean + /** The live activity line discloses this open reasoning block, so its row draws nothing. */ + reasoningIsLive: boolean + /** A reasoning row's disclosure, keyed like the live line's so an opened block stays open. */ + reasoningExpanded: boolean + onToggleReasoning: (key: string) => void +} + +/** The live activity line, when it draws, and whether the reader opened the block it discloses. */ +export type MobileNativeChatLiveLine = NativeChatLiveLine & { reasoningExpanded: boolean } + +/** Keys the reader opened in this chat, bounded; another chat's never leak in. */ +function useScopedOpenKeys(scopeKey: string): [ReadonlySet, (key: string) => void] { + const [state, setState] = useState<{ scopeKey: string; keys: ReadonlySet }>(() => ({ + scopeKey, + keys: new Set() + })) + const toggle = useCallback( + (key: string) => { + setState((current) => { + const next = new Set(current.scopeKey === scopeKey ? current.keys : []) + if (!next.delete(key)) { + if (next.size >= MAX_EXPANDED_TURNS) { + const oldest = next.values().next().value + if (oldest) { + next.delete(oldest) + } + } + next.add(key) + } + return { scopeKey, keys: next } + }) + }, + [scopeKey] + ) + return [state.scopeKey === scopeKey ? state.keys : EMPTY_TURN_IDS, toggle] } /** Owns the transcript's per-turn status rows and their disclosure state, and @@ -41,6 +82,7 @@ export function useMobileNativeChatTurnDisclosure({ turnJournal = null, thinking = false, activityText = null, + lineYields = false, scopeKey }: { messages: readonly NativeChatMessage[] @@ -55,6 +97,8 @@ export function useMobileNativeChatTurnDisclosure({ thinking?: boolean /** What the provider says the live turn is doing; outranks the other labels. */ activityText?: string | null + /** A prompt the reader must answer replaces the live activity line. */ + lineYields?: boolean /** Host/worktree/tab identity for timing and disclosure isolation. */ scopeKey: string }): { @@ -67,6 +111,10 @@ export function useMobileNativeChatTurnDisclosure({ listMessages: readonly NativeChatMessage[] /** Rows waiting behind the live turn, drawn after its live status. */ waitingRows: readonly { item: NativeChatMessage; index: number }[] + /** The live activity line, or null while the turn is idle or a prompt replaces it. */ + liveLine: MobileNativeChatLiveLine | null + /** Stable for a given chat scope, so it never disturbs a row's memo. */ + onToggleReasoning: (key: string) => void } { // Resolve each row's turn, which turn is live, and the order the rows draw in, once: from the // turn record when the host states scopes, else by journal order. @@ -103,34 +151,39 @@ export function useMobileNativeChatTurnDisclosure({ thinking, scopeKey }) - const [expandedTurns, setExpandedTurns] = useState<{ - scopeKey: string - turnIds: ReadonlySet - }>(() => ({ scopeKey, turnIds: new Set() })) - const expandedTurnIds = - expandedTurns.scopeKey === scopeKey ? expandedTurns.turnIds : EMPTY_TURN_IDS - const toggleExpandedTurn = useCallback( - (turnKey: string) => { - setExpandedTurns((current) => { - const next = new Set(current.scopeKey === scopeKey ? current.turnIds : []) - if (!next.delete(turnKey)) { - if (next.size >= MAX_EXPANDED_TURNS) { - const oldest = next.values().next().value - if (oldest) { - next.delete(oldest) - } - } - next.add(turnKey) - } - return { scopeKey, turnIds: next } - }) - }, - [scopeKey] - ) + const [expandedTurnIds, toggleExpandedTurn] = useScopedOpenKeys(scopeKey) + const [expandedReasoning, toggleReasoning] = useScopedOpenKeys(scopeKey) const bars = useMemo(() => nativeChatTurnBarRows(rows, turnKeys), [rows, turnKeys]) const { active, activeTurnKey, completedByTurn } = turnStatuses + const inLiveWorkingTurn = useCallback( + (index: number) => + isNativeChatRowInLiveWorkingTurn(turnKeys[index], liveTurnKey, enabled && isWorking), + [enabled, isWorking, liveTurnKey, turnKeys] + ) const activeActivityText = enabled && isWorking ? (activityText ?? null) : null + const line = useMemo( + () => + nativeChatLiveLine({ + draws: enabled && isWorking && !lineYields && active !== null, + thinking: active?.thinking === true, + activityText: activeActivityText, + messages: rows, + inLiveWorkingTurn + }), + [active, activeActivityText, enabled, inLiveWorkingTurn, isWorking, lineYields, rows] + ) + const liveReasoningId = line?.reasoning?.message.id + const liveLine = useMemo( + () => + line && { + ...line, + reasoningExpanded: + line.reasoning !== null && + expandedReasoning.has(nativeChatReasoningDisclosureKey(line.reasoning.message.id)) + }, + [expandedReasoning, line] + ) const resolveRow = useCallback( (listIndex: number, message: NativeChatMessage): MobileNativeChatTurnRow => { const index = waiting.indexById?.get(message.id) ?? listIndex @@ -153,23 +206,28 @@ export function useMobileNativeChatTurnDisclosure({ // transcript, defeating the row's memo; caching one per turn would mean // writing a ref during render, which react-freeze can discard. turnKey: turnKey && turnStatus?.workedSeconds != null ? turnKey : undefined, - // Liveness is the live turn's rows, not the newest prompt's: a running turn's rows stay live - // while a newer message waits behind it. With no user boundary at all, the session's - // working state stays authoritative. - activeTurnIsWorking: enabled && isWorking && turnKey === liveTurnKey + // With no user boundary at all, the session's working state stays authoritative. + activeTurnIsWorking: inLiveWorkingTurn(index), + reasoningIsLive: message.id === liveReasoningId, + reasoningExpanded: + message.role === 'reasoning' && + expandedReasoning.has(nativeChatReasoningDisclosureKey(message.id)), + onToggleReasoning: toggleReasoning } }, [ turnKeys, waiting, bars, - liveTurnKey, enabled, activeTurnKey, active, completedByTurn, expandedTurnIds, - isWorking + inLiveWorkingTurn, + liveReasoningId, + expandedReasoning, + toggleReasoning ] ) @@ -180,6 +238,8 @@ export function useMobileNativeChatTurnDisclosure({ onToggleTurn: toggleExpandedTurn, resolveRow, listMessages: waiting.listMessages, - waitingRows: waiting.waitingRows + waitingRows: waiting.waitingRows, + liveLine, + onToggleReasoning: toggleReasoning } } diff --git a/src/main/claude/claude-hook-event-versions.ts b/src/main/claude/claude-hook-event-versions.ts index 5c9c28de66c5..2e980cd04775 100644 --- a/src/main/claude/claude-hook-event-versions.ts +++ b/src/main/claude/claude-hook-event-versions.ts @@ -60,7 +60,12 @@ export function claudeKnowsStatusLine(version: string | null | undefined): boole return claudeKnowsSince(version, CLAUDE_STATUS_LINE_FIRST_VERSION) } -export async function probeClaudeCliVersion(executablePath: string): Promise { +/** `launch` runs the probe with the cwd and env a launch will spawn the CLI with, so a version + * manager's shim picks the same CLI the launch will. */ +export async function probeClaudeCliVersion( + executablePath: string, + launch?: { cwd: string; env: Record; timeoutMs?: number } +): Promise { try { const pathKey = process.platform === 'win32' && process.env.Path !== undefined ? 'Path' : 'PATH' const executableDir = path.dirname(executablePath) @@ -70,13 +75,14 @@ export async function probeClaudeCliVersion(executablePath: string): Promise streamedBlocks: ReturnType streamedText: ReturnType + streamedThinking: ClaudeStreamedThinking subagents: ClaudeSubagentRoster toolOrigins: ClaudeToolOriginRegistry backgroundTasks: ClaudeBackgroundTaskRows @@ -97,10 +92,11 @@ export function journalClaudeMessage( const outputEnvelope = claudeOutputEnvelope(envelope) const body = claudeMessageBody(outputEnvelope) const identity = - (body && envelope.role === 'assistant' ? ctx.streamedBlocks.reconcile(envelope) : null) ?? - claudeMessageIdentity(envelope) + (body && envelope.role === 'assistant' + ? ctx.streamedBlocks.reconcile(envelope)?.identity + : null) ?? claudeMessageIdentity(envelope) ctx.streamedText.forget(agentJournalItemKey(identity)) - const thinking = claudeThinkingText(outputEnvelope) + const thinking = ctx.streamedThinking.finalize(outputEnvelope, observedAt) const source: ClaudeTurnSource = { sessionId: envelope.sessionId, uuid: envelope.uuid, @@ -155,15 +151,12 @@ export function journalClaudeMessage( } if (thinking) { ctx.turn.ensureOpen(message, source, observedAt) - const thinkingIdentity = claudeThinkingIdentity(envelope.sessionId, envelope.uuid) - const thinkingBody: AgentJournalItemBody = { - kind: 'message', - role: 'reasoning', - blocks: [ - { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - ] - } - ctx.sink.appendItem(thinkingIdentity, thinkingBody, stamp(thinkingIdentity, thinkingBody)) + // The write that ends the row: shed under pressure, the row would read open for good. + ctx.sink.appendItem(thinking.identity, thinking.body, { + ...stamp(thinking.identity, thinking.body), + ...(thinking.startedAt === undefined ? {} : { observedAt: thinking.startedAt }), + lifecycle: true + }) changed = true } changed = diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index 32149bf21f34..84b4ed44c68f 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -26,6 +26,8 @@ export type ClaudeOpenTurnDeps = { sink: StructuredAgentSessionEventSink /** Settles the superseded turn's children; they get no later event of their own. */ settleChildren: (groupKey: string | null) => void + /** Ends what the ending turn left open, at the instant it ended; no later frame will. */ + endOpenWork: (completedAt: number) => void /** A turn opening moves the conversation on. */ onOpen?: () => void } @@ -111,6 +113,7 @@ export class ClaudeOpenTurn { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) + this.deps.endOpenWork(observedAt) this.publish(this.current, { state: 'interrupted', completedAt: observedAt, @@ -128,6 +131,7 @@ export class ClaudeOpenTurn { this.deps.onOpen?.() if (this.current) { this.deps.settleChildren(this.groupKey) + this.deps.endOpenWork(turn.startedAt) this.publish(this.current, { state: 'interrupted', completedAt: turn.startedAt, @@ -168,6 +172,8 @@ export class ClaudeOpenTurn { settle(end: ClaudeTurnEnd, contextUsage?: AgentSessionContextUsage): void { // Every settle is a provider cycle ending (result, idle, child exit). this.cycleWorkObserved = false + // Even with no turn open: work a suppressed turn produced still ends here. + this.deps.endOpenWork(end.completedAt) if (this.current) { this.publish(this.current, end, contextUsage) this.current = null diff --git a/src/main/claude/claude-streamed-block-identity.ts b/src/main/claude/claude-streamed-block-identity.ts index 00bfdd103ab6..3ac025c1b082 100644 --- a/src/main/claude/claude-streamed-block-identity.ts +++ b/src/main/claude/claude-streamed-block-identity.ts @@ -14,24 +14,29 @@ export type ClaudeStreamedTextDelta = { * prose has no message envelope when it is persisted, so the producer travels * with the delta rather than being re-read from a frame that is long gone. */ parentToolUseId: string | null + /** Host clock when the block began: its `content_block_start`, else its first delta. Text can + * trail the start by seconds, so this, not the first write, is when the block started. */ + startedAt: number } +export type StreamedBlock = { identity: AgentJournalItemIdentity; startedAt: number } + type StreamedMessage = { messageId: string | null - blocks: Map + blocks: Map /** Streamed text blocks whose final assistant frame has not arrived, in block order. */ - awaitingFinal: AgentJournalItemIdentity[] + awaitingFinal: StreamedBlock[] } export type ClaudeStreamedBlockRegistry = { /** Text a stream_event frame appends to its block, or null when it carries none. */ - observe: (frame: Record) => ClaudeStreamedTextDelta | null - /** The streamed identity a final assistant frame reconciles onto, if its block streamed. */ + observe: (frame: Record, observedAt: number) => ClaudeStreamedTextDelta | null + /** The streamed block a final assistant frame reconciles onto, if its block streamed. */ reconcile: (frame: { sessionId: string parentToolUseId: string | null messageId: string | null - }) => AgentJournalItemIdentity | null + }) => StreamedBlock | null clear: () => void } @@ -39,7 +44,9 @@ function scopeKey(sessionId: string, parentToolUseId: string | null): string { return `${sessionId}/${parentToolUseId ?? ''}` } -export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry { +export function createClaudeStreamedBlockRegistry( + blockType: 'text' | 'thinking' = 'text' +): ClaudeStreamedBlockRegistry { const messages = new Map() const messageFor = (scope: string): StreamedMessage => { @@ -55,16 +62,18 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry streamed: StreamedMessage, sessionId: string, index: number, - uuid: string - ): AgentJournalItemIdentity => { + uuid: string, + startedAt: number + ): StreamedBlock => { const identity: AgentJournalItemIdentity = { provider: 'claude', sessionId, uuid } - streamed.blocks.set(index, identity) - streamed.awaitingFinal.push(identity) - return identity + const block = { identity, startedAt } + streamed.blocks.set(index, block) + streamed.awaitingFinal.push(block) + return block } return { - observe: (frame) => { + observe: (frame, observedAt) => { const event = claudeRecord(frame.event) const sessionId = claudeText(frame.session_id) const uuid = claudeText(frame.uuid) @@ -84,24 +93,25 @@ export function createClaudeStreamedBlockRegistry(): ClaudeStreamedBlockRegistry const index = typeof event.index === 'number' ? event.index : 0 if (event.type === 'content_block_start') { const block = claudeRecord(event.content_block) - if (block?.type !== 'text') { + if (block?.type !== blockType) { return null } - const identity = mint(messageFor(scope), sessionId, index, uuid) - const text = claudeText(block.text) - return text ? { identity, text, parentToolUseId } : null + const started = mint(messageFor(scope), sessionId, index, uuid, observedAt) + const text = claudeText(block[blockType]) + return text ? { ...started, text, parentToolUseId } : null } if (event.type !== 'content_block_delta') { return null } const delta = claudeRecord(event.delta) - const text = delta?.type === 'text_delta' ? claudeText(delta.text) : null + const text = delta?.type === `${blockType}_delta` ? claudeText(delta[blockType]) : null if (!text) { return null } const streamed = messageFor(scope) - const identity = streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid) - return { identity, text, parentToolUseId } + const started = + streamed.blocks.get(index) ?? mint(streamed, sessionId, index, uuid, observedAt) + return { ...started, text, parentToolUseId } }, reconcile: (frame) => { const streamed = messages.get(scopeKey(frame.sessionId, frame.parentToolUseId)) diff --git a/src/main/claude/claude-streamed-text-checkpoints.ts b/src/main/claude/claude-streamed-text-checkpoints.ts index ad21ee790a60..cf8d3c40be5b 100644 --- a/src/main/claude/claude-streamed-text-checkpoints.ts +++ b/src/main/claude/claude-streamed-text-checkpoints.ts @@ -9,11 +9,13 @@ import { import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' export type ClaudeStreamedTextCheckpointDeps = { - /** Rewrites the block's journal row with the text accumulated so far. */ + /** Rewrites the block's journal row with the text accumulated so far. `ended` is set on the + * block's last write, when it ends without the final frame that would otherwise replace it. */ persist: ( identity: AgentJournalItemIdentity, text: string, - options: StructuredAgentSessionAppendOptions + options: StructuredAgentSessionAppendOptions, + ended?: ClaudeStreamedBlockEnd ) => void /** Who produced a block, asked by the scope the block streamed under. */ producer: ClaudeSubagentLinkageSource @@ -21,6 +23,9 @@ export type ClaudeStreamedTextCheckpointDeps = { schedule?: AgentSessionDeltaCoalescerDeps['schedule'] } +/** How a block ended: `completedAt` is when the host saw it end, or saw what cut it off. */ +export type ClaudeStreamedBlockEnd = { completedAt?: number } + export type ClaudeStreamedTextCheckpoints = { /** Accumulate a delta; the row is rewritten on the coalescer's own cadence. */ append: ( @@ -36,6 +41,11 @@ export type ClaudeStreamedTextCheckpoints = { reattribute: () => void /** Drop one block's state, for a block whose final frame has now landed. */ forget: (key: string) => void + /** The text received for a block so far, if it is still streaming. */ + latest: (key: string) => string | undefined + /** Write each matching block's text one last time as ended, then drop it: its final frame is + * never coming. A block with no text has no row and is only dropped. */ + finish: (ended: ClaudeStreamedBlockEnd, only?: (key: string) => boolean) => void /** * Drop every block still awaiting its final frame, at turn settlement. Their * text is already journaled by the flush that precedes settlement; keeping it @@ -156,6 +166,20 @@ export function createClaudeStreamedTextCheckpoints( } }, forget: drop, + latest: (key) => coalescer.snapshot(key)?.text ?? latestText.get(key), + finish: (ended, only) => { + for (const [key, identity] of identities) { + if (only && !only(key)) { + continue + } + coalescer.flush(key) + const text = latestText.get(key) + if (text !== undefined) { + deps.persist(identity, text, producerOptions(key), ended) + } + drop(key) + } + }, settle: () => { // Map iteration tolerates deletion of the entry just visited. for (const key of identities.keys()) { diff --git a/src/main/claude/claude-streamed-thinking.ts b/src/main/claude/claude-streamed-thinking.ts new file mode 100644 index 000000000000..1edd109adebf --- /dev/null +++ b/src/main/claude/claude-streamed-thinking.ts @@ -0,0 +1,156 @@ +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentJournalTurnScope +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + endedJournalReasoning, + journalReasoningBody +} from '../native-chat/agent-session-journal/journal-reasoning-row' +import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' +import { + createClaudeStreamedTextCheckpoints, + type ClaudeStreamedBlockEnd +} from './claude-streamed-text-checkpoints' +import { + claudeRecord, + claudeText, + claudeThinkingIdentity, + claudeThinkingText, + type ClaudeMessageEnvelope +} from './claude-structured-item-translation' +import type { ClaudeSubagentLinkageSource } from './claude-subagent-linkage' + +/** The stream a frame belongs to, which a new message in it starts over. */ +function streamScope(frame: Record): string | null { + const event = claudeRecord(frame.event) + const sessionId = claudeText(frame.session_id) + if (frame.type !== 'stream_event' || event?.type !== 'message_start' || !sessionId) { + return null + } + return `${sessionId}/${claudeText(frame.parent_tool_use_id) ?? ''}` +} + +export type ClaudeStreamedThinking = ReturnType + +/** A thinking block's closing row, and when the block began if it was seen streaming. */ +export type ClaudeReasoningFinal = { + identity: AgentJournalItemIdentity + body: AgentJournalMessageItem + startedAt?: number +} + +/** Thinking blocks streamed under --include-partial-messages, written to the same row their + * final assistant frame later lands on, and open until that frame or the turn's end. */ +export function createClaudeStreamedThinking(deps: { + sink: StructuredAgentSessionEventSink + producer: ClaudeSubagentLinkageSource + turnScope: () => AgentJournalTurnScope + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +}) { + const blocks = createClaudeStreamedBlockRegistry('thinking') + /** Each open block's stream, and when it began. */ + const open = new Map() + const checkpoints = createClaudeStreamedTextCheckpoints({ + ...(deps.coalesceMs === undefined ? {} : { coalesceMs: deps.coalesceMs }), + ...(deps.schedule ? { schedule: deps.schedule } : {}), + producer: deps.producer, + persist: (identity, text, options, ended) => { + const body = journalReasoningBody( + text, + ended ? endedJournalReasoning(ended.completedAt) : { state: 'running' } + ) + if (body) { + const startedAt = open.get(agentJournalItemKey(identity))?.startedAt + deps.sink.appendItem(identity, body, { + ...options, + turnScope: deps.turnScope(), + // The row starts with its block, not with its first text or a queued write. + ...(startedAt === undefined ? {} : { observedAt: startedAt }), + // An end the sink sheds under pressure would leave the row open with nothing to close it. + ...(ended ? { lifecycle: true } : {}) + }) + deps.sink.publish() + } + } + }) + const finish = (ended: ClaudeStreamedBlockEnd, scope?: string): void => { + checkpoints.finish( + ended, + scope === undefined ? undefined : (key) => open.get(key)?.scope === scope + ) + for (const [key, block] of open) { + if (scope === undefined || block.scope === scope) { + open.delete(key) + } + } + } + + return { + /** True when the frame carried thinking text. */ + observe: (frame: Record, observedAt: number): boolean => { + // A new message in a stream means the previous one's unfinished blocks are never finishing. + const restarted = streamScope(frame) + if (restarted !== null) { + finish({ completedAt: observedAt }, restarted) + } + const delta = blocks.observe(frame, observedAt) + if (!delta) { + return false + } + const identity = delta.identity + if (identity.provider === 'claude') { + open.set(agentJournalItemKey(identity), { + scope: `${identity.sessionId}/${delta.parentToolUseId ?? ''}`, + startedAt: delta.startedAt + }) + } + checkpoints.append(identity, delta.text, delta.parentToolUseId) + return true + }, + /** The row a final frame's thinking lands on — its streamed block's, else its own — closed. + * Only a block seen streaming has an observed end. */ + finalize: ( + envelope: ClaudeMessageEnvelope, + observedAt: number + ): ClaudeReasoningFinal | null => { + if (!envelope.content.some((part) => claudeRecord(part)?.type === 'thinking')) { + return null + } + const streamed = blocks.reconcile(envelope) + const identity = + streamed?.identity ?? claudeThinkingIdentity(envelope.sessionId, envelope.uuid) + const key = agentJournalItemKey(identity) + // A final frame with no text of its own still ends the row its stream wrote. + const text = claudeThinkingText(envelope) ?? checkpoints.latest(key) ?? '' + checkpoints.forget(key) + open.delete(key) + const body = journalReasoningBody( + text, + endedJournalReasoning(streamed ? observedAt : undefined) + ) + return body + ? { identity, body, ...(streamed ? { startedAt: streamed.startedAt } : {}) } + : null + }, + /** End every block still open, for a turn that is ending. */ + finishOpen: (completedAt: number): void => { + finish({ completedAt }) + blocks.clear() + }, + flush: checkpoints.flush, + reattribute: checkpoints.reattribute, + dispose: (): void => { + blocks.clear() + open.clear() + checkpoints.dispose() + }, + get pending() { + return checkpoints.pending + } + } +} diff --git a/src/main/claude/claude-structured-journal-translation-context-usage.test.ts b/src/main/claude/claude-structured-journal-translation-context-usage.test.ts index 95f2b17f902d..534600c71ead 100644 --- a/src/main/claude/claude-structured-journal-translation-context-usage.test.ts +++ b/src/main/claude/claude-structured-journal-translation-context-usage.test.ts @@ -337,7 +337,12 @@ describe('context usage on journal rows', () => { it('counts a turn opening as activity, whatever frame opened it', () => { const onOpen = vi.fn() - const turn = new ClaudeOpenTurn({ sink: journal().sink, settleChildren: () => {}, onOpen }) + const turn = new ClaudeOpenTurn({ + sink: journal().sink, + settleChildren: () => {}, + endOpenWork: () => {}, + onOpen + }) turn.open( { sessionId: 'claude-session', turnId: 'turn-a', startedAt: 1_000, userItemId: 'turn-a' }, 1_000 diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 4aa02ed525cb..7a9a8d64f562 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -638,7 +638,8 @@ describe('Claude structured journal translation', () => { role: 'reasoning', blocks: [ { type: 'text', text: boundInlineText(thinking, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } - ] + ], + state: 'completed' }) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 070798bc5216..e2932e0d3468 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -9,12 +9,14 @@ import type { ClaudePromptRegistry } from './claude-structured-prompt-replies' import { claudeProviderFrameActivity } from '../native-chat/agent-session-wire/provider-frame-activity' import { claudeProviderFrameKind, - createClaudeProviderFrameFallback + createClaudeProviderFrameFallback, + isClaudeProgressFrame } from './claude-structured-provider-fallback' import { taskFrameSentence } from './claude-background-task-frames' import { ClaudeBackgroundTaskRows } from './claude-background-task-rows' import { ClaudeToolOriginRegistry } from './claude-tool-origin-registry' import { ClaudeProvisionalRowCorrections } from './claude-provisional-row-corrections' +import { createClaudeStreamedThinking } from './claude-streamed-thinking' import { ClaudeSubagentRoster } from './claude-subagent-roster' import { ClaudeJournaledRoster } from './claude-subagent-journaled-roster' import { createClaudeStreamedBlockRegistry } from './claude-streamed-block-identity' @@ -76,6 +78,7 @@ export function createClaudeJournalTranslator( const turn = new ClaudeOpenTurn({ sink: deps.sink, settleChildren: (groupKey) => subagents.settleTurn(groupKey), + endOpenWork: (completedAt) => streamedThinking.finishOpen(completedAt), onOpen: () => context.markActivity() }) const context = new ClaudeContextFacts(turn, deps.sink) @@ -135,6 +138,15 @@ export function createClaudeJournalTranslator( deps.sink.publish() } }) + const streamedThinking = createClaudeStreamedThinking({ + ...deps, + producer: subagents.linkage, + turnScope + }) + const flush = (): void => { + streamedText.flush() + streamedThinking.flush() + } const publishActivity = (kind: string, payload: unknown): void => { const turnId = turn.id @@ -148,16 +160,17 @@ export function createClaudeJournalTranslator( } const handleStream = (message: Record, observedAt: number): boolean => { - const delta = streamedBlocks.observe(message) - // `message_start` is the provider's turn boundary. Keep the first text + const delta = streamedBlocks.observe(message, observedAt) + const thinking = streamedThinking.observe(message, observedAt) + // `message_start` is the provider's turn boundary. Keep the first content // delta as a compatibility fallback for streams that omit it. - const source = delta ? claudeStreamTurnSource(message) : claudeStreamTurnStartSource(message) + const source = + delta || thinking ? claudeStreamTurnSource(message) : claudeStreamTurnStartSource(message) turn.ensureOpen(message, source, observedAt) - if (!delta) { - return false + if (delta) { + streamedText.append(delta.identity, delta.text, delta.parentToolUseId) } - streamedText.append(delta.identity, delta.text, delta.parentToolUseId) - return true + return delta !== null || thinking } const messageContext: ClaudeMessageJournalContext = { @@ -165,6 +178,7 @@ export function createClaudeJournalTranslator( tools, streamedBlocks, streamedText, + streamedThinking, subagents, toolOrigins, backgroundTasks, @@ -186,7 +200,7 @@ export function createClaudeJournalTranslator( handle: (event) => { if (event.type === 'ended') { prompts.retryPendingCancellations() - streamedText.flush() + flush() subagents.settleSession() backgroundTasks.settleSession() // The host saw the child end, so the turn's end is observed, not lost. Whether it was a @@ -225,10 +239,14 @@ export function createClaudeJournalTranslator( // writes, so an announcement landing in this same pass has to be visible // to it or the row is stamped provisionally one line too early. const announced = event.type === 'message' && subagents.observeSystemFrame(event.message) - streamedText.flush() + // Only ahead of a frame that can write a row: one per thinking token rewrote the whole row. + if (!(event.type === 'message' && isClaudeProgressFrame(event.message))) { + flush() + } if (announced) { corrections.retry() streamedText.reattribute() + streamedThinking.reattribute() } if (event.type === 'prompt') { prompts.handle(event) @@ -289,12 +307,12 @@ export function createClaudeJournalTranslator( get openTurnInLiveProviderCycle() { return turn.openedInLiveProviderCycle }, - flush: streamedText.flush, + flush, childToolOwner: childQueries.childToolOwner, childActivity: childQueries.childActivity, retryPendingTaskRows: () => backgroundTasks.retryPendingWrites(), get pendingStreamedBlocks() { - return streamedText.pending + return streamedText.pending + streamedThinking.pending }, get contextActivity() { return context.activityRevision @@ -305,9 +323,10 @@ export function createClaudeJournalTranslator( modelMayHaveChanged: () => context.modelMayHaveChanged(), modelWritten: (model) => context.modelWritten(model), dispose: () => { - streamedText.flush() + flush() context.dispose() streamedText.dispose() + streamedThinking.dispose() tools.clear() prompts.clear() streamedBlocks.clear() diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 9db732d3430d..ef75a6ba2b9a 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -1,6 +1,6 @@ import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' +import { delimiter, dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -13,9 +13,11 @@ import { CLAUDE_SESSION_STATE_EVENTS_ENV, CLAUDE_STRUCTURED_BASE_OPTIONS, claudeSessionIdForOrcaSession, - createClaudeStructuredLaunchResolver + createClaudeStructuredLaunchResolver, + type ClaudeStructuredLaunchResolverDeps } from './claude-structured-launch-resolution' import { claudeStructuredPermissionModeForSettings } from './claude-structured-permission-mode' +import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' const SESSION_ID = 'orca-session-1' @@ -521,3 +523,86 @@ describe('claude structured launch resolution', () => { }) }) }) + +describe('readable Claude thinking', () => { + const launchWith = ( + thinkingDisplay?: ClaudeStructuredLaunchResolverDeps['thinkingDisplay'], + authSwitchSettleTimeoutMs?: number, + command = '/usr/local/bin/claude' + ) => + createClaudeStructuredLaunchResolver({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch resolution reads only getRecord. + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async (id) => `/repos/${id}`, + resolveCommand: () => command, + resolveAuthPolicy: () => ({ stripAuthEnv: false }), + resolveEnv: () => ({ PROJECT_SHIM: '1', ANTHROPIC_API_KEY: 'sk-user' }), + hasTranscript: async () => false, + ...(thinkingDisplay ? { thinkingDisplay } : {}), + ...(authSwitchSettleTimeoutMs === undefined ? {} : { authSwitchSettleTimeoutMs }) + })({ identity: IDENTITY }) + + // Whether the CLI's directory holds a `node` decides if the runtime pairing puts that directory + // first on PATH (Linux CI's /usr/local/bin does, a Mac's usually does not), so both are pinned. + it.each([ + ['without a sibling Node runtime', false], + ['with a sibling Node runtime', true] + ])( + 'probes the CLI the launch runs, on its PATH and shims, without its credentials (%s)', + async (_, sibling) => { + const argsFor = vi.fn( + async (_launch: { command: string; cwd: string; env: Record }) => ({ + 'thinking-display': 'summarized' + }) + ) + const binDir = join(mkdtempSync(join(tmpdir(), 'orca-claude-probe-')), 'bin') + const command = join(binDir, process.platform === 'win32' ? 'claude.cmd' : 'claude') + makeExecutable(command) + if (sibling) { + makeExecutable(join(binDir, process.platform === 'win32' ? 'node.cmd' : 'node')) + } + const launch = await launchWith({ argsFor }, undefined, command) + const asked = argsFor.mock.calls[0]?.[0] + expect(asked).toMatchObject({ command, cwd: '/repos/workspace-1' }) + const segments = (env: Record | undefined) => + (env?.PATH ?? env?.Path ?? '').split(delimiter) + // The launch's PATH is the probe's plus Orca's own CLI directory, which holds no `claude` or + // runtime, so both resolve the same binary and the same shims in the same order. + const orcaCliDir = launch.env?.ORCA_CLI_COMMAND ? dirname(launch.env.ORCA_CLI_COMMAND) : null + expect(segments(launch.env).filter((dir) => dir !== orcaCliDir)).toEqual(segments(asked?.env)) + expect(segments(asked?.env)[0] === binDir).toBe(sibling) + expect(asked?.env).toMatchObject({ PROJECT_SHIM: '1' }) + expect(asked?.env).not.toHaveProperty('ANTHROPIC_API_KEY') + // The launch keeps the credential the user gave it. + expect(launch.env).toMatchObject({ ANTHROPIC_API_KEY: 'sk-user' }) + expect(launch.options.extraArgs).toEqual({ + 'replay-user-messages': null, + 'thinking-display': 'summarized' + }) + expect(launch.options).not.toHaveProperty('thinking') + } + ) + + it('passes nothing when the CLI is not known to take the flag, or nothing can say', async () => { + const launch = await launchWith({ argsFor: async () => ({}) }) + expect(launch.options.extraArgs).toEqual({ 'replay-user-messages': null }) + expect((await launchWith()).options.extraArgs).toEqual({ 'replay-user-messages': null }) + }) + + it('still rechecks an account switch that began while the probe ran', async () => { + try { + const launch = launchWith( + { + argsFor: async () => { + beginClaudeAuthSwitch() + return {} + } + }, + 10 + ) + await expect(launch).rejects.toMatchObject({ reason: 'accountSwitchInProgress' }) + } finally { + endClaudeAuthSwitch() + } + }) +}) diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index e20c1bd8b8ee..5f1edeb017a1 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -33,6 +33,7 @@ import { import { resolveClaudeCommand } from '../codex-cli/command' import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' +import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' @@ -133,6 +134,8 @@ export type ClaudeStructuredLaunchResolverDeps = { authSwitchSettleTimeoutMs?: number /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** Whether this CLI takes the thinking-display flag. Absent ⇒ the flag is never passed. */ + thinkingDisplay?: Pick /** Whether Claude wrote a transcript for this id; defaults to the transcript resolver. */ hasTranscript?: (input: { providerSessionId: string @@ -152,6 +155,67 @@ async function claudeTranscriptExists(input: { export type ClaudeStructuredInvocation = { command: string; env: Record } +type ClaudeEnvDeps = Pick< + ClaudeStructuredLaunchResolverDeps, + 'resolveCommand' | 'resolveEnv' | 'resolveInheritedEnv' +> + +/** What a child's env is built from, before any auth policy applies to it. */ +export type ClaudeChildEnvSources = { + command: string + overlay: Record | undefined + inheritedEnv: Record +} + +export async function resolveClaudeChildEnvSources( + deps: ClaudeEnvDeps +): Promise { + const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const overlay = await deps.resolveEnv?.() + const inheritedEnv = deps.resolveInheritedEnv + ? await deps.resolveInheritedEnv() + : cloneDefinedEnv(process.env) + return { command, overlay: overlay ? cloneDefinedEnv(overlay) : undefined, inheritedEnv } +} + +// Why the overlay merges onto the inherited env rather than replacing it: the child +// still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath +// derives PATH from what it is handed. Ambient Anthropic auth is stripped from the +// inherited half only when a managed account owns the credential; a system-auth +// user's own key is their sign-in and must reach the child. +function claudeChildEnv( + sources: ClaudeChildEnvSources, + stripAuthEnv: boolean, + decorateEnv: (env: Record) => Record = (env) => env +): Record { + return withCliRuntimeOnPath( + sources.command, + decorateEnv({ + ...applyClaudeEnvPatch( + withoutInheritedClaudeConfigDir(sources.inheritedEnv, process.platform), + {}, + { stripAuthEnv, platform: process.platform } + ), + ...sources.overlay + }), + { platform: process.platform } + ) +} + +/** The env a `--version` probe runs with: the launch's own env, PATH and shims included, minus the + * Claude auth variables, auth-like custom headers and an inherited CLAUDE_CONFIG_DIR, which asking + * a version needs none of. Everything else is what this same binary receives at launch anyway. */ +export function claudeProbeEnv(sources: ClaudeChildEnvSources): Record { + return applyClaudeEnvPatch( + claudeChildEnv(sources, true), + {}, + { + stripAuthEnv: true, + platform: process.platform + } + ) +} + /** * The one place a structured Claude child's binary and environment are * resolved. The session launch and the session-less catalog probe both build @@ -160,49 +224,30 @@ export type ClaudeStructuredInvocation = { command: string; env: Record & { authSwitchSettleTimeoutMs?: number }, - decorateEnv: (env: Record) => Record = (env) => env + deps: ClaudeEnvDeps & + Pick & { + authSwitchSettleTimeoutMs?: number + }, + decorateEnv: (env: Record) => Record = (env) => env, + /** Already resolved by a caller that needed them earlier; read again otherwise. */ + resolvedSources?: ClaudeChildEnvSources ): Promise { - const command = (deps.resolveCommand ?? resolveClaudeCommand)() + const sources = resolvedSources ?? (await resolveClaudeChildEnvSources(deps)) const auth = await deps.resolveAuthPolicy() - const overlay = await deps.resolveEnv?.() - const inheritedEnv = deps.resolveInheritedEnv - ? await deps.resolveInheritedEnv() - : cloneDefinedEnv(process.env) // A switch can begin while the policy and overlay resolve, exactly as it can // during the terminal preflight's prepareClaudeAuth — recheck after the awaits. await assertClaudeAuthSwitchSettled(deps.authSwitchSettleTimeoutMs) // Under a managed account the pinned credential is the only auth this launch may // use, so an explicit override is refused rather than silently beating the pin. - if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(overlay)) { + if (auth.stripAuthEnv && hasClaudeAuthEnvConflict(sources.overlay)) { throw new AgentSessionPreSpawnError(new Error(CLAUDE_AUTH_ENV_CONFLICT_MESSAGE), { reason: 'managedAccountEnvOverride' }) } - // Why the overlay merges onto the inherited env rather than replacing it: the child - // still needs PATH and the rest of the shell environment, and withCliRuntimeOnPath - // derives PATH from what it is handed. Ambient Anthropic auth is stripped from the - // inherited half only when a managed account owns the credential; a system-auth - // user's own key is their sign-in and must reach the child. - const env = withCliRuntimeOnPath( - command, - decorateEnv({ - ...applyClaudeEnvPatch( - withoutInheritedClaudeConfigDir(inheritedEnv, process.platform), - {}, - { - stripAuthEnv: auth.stripAuthEnv, - platform: process.platform - } - ), - ...(overlay ? cloneDefinedEnv(overlay) : {}) - }), - { platform: process.platform } - ) - return { command, env } + return { + command: sources.command, + env: claudeChildEnv(sources, auth.stripAuthEnv, decorateEnv) + } } /** @@ -280,6 +325,14 @@ export function createClaudeStructuredLaunchResolver( ? head.nativeId : claudeSessionIdForOrcaSession(identity.sessionId) const continuesChain = head !== null + const cwd = await deps.resolveWorkspacePath(record.location.workspaceId) + const sources = await resolveClaudeChildEnvSources(deps) + // Asked as soon as the spawn's cwd and PATH are known, so it overlaps what is left to resolve. + const thinkingDisplay = deps.thinkingDisplay?.argsFor({ + command: sources.command, + cwd, + env: claudeProbeEnv(sources) + }) // A start that failed before its first turn wrote no transcript, and `--resume` of an absent // one exits; launch that id fresh instead. With a transcript, `--session-id` would collide. const resumesTranscript = @@ -294,24 +347,33 @@ export function createClaudeStructuredLaunchResolver( const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) - const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => - // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. - structuredSessionChildIdentityEnv(record.sessionId, { - ...base, - // The turn translator relies on Claude's authoritative idle frame when no result arrives. - [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' - }) + const thinkingDisplayArgs = (await thinkingDisplay) ?? {} + // Last: it rechecks the account switch, which may have begun during any await above. + const { command, env } = await resolveClaudeStructuredInvocation( + deps, + (base) => + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv(record.sessionId, { + ...base, + // The turn translator relies on Claude's authoritative idle frame when no result arrives. + [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' + }), + sources ) return { pathToClaudeCodeExecutable: command, options: { ...CLAUDE_STRUCTURED_BASE_OPTIONS, ...permission, - extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...permission.extraArgs }, + extraArgs: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, + ...permission.extraArgs, + ...thinkingDisplayArgs + }, // Claude owns where a resumed conversation continues; the stored leaf is Orca's bookkeeping. ...(resumesTranscript ? { resume: providerSessionId } : { sessionId: providerSessionId }) }, - cwd: await deps.resolveWorkspacePath(record.location.workspaceId), + cwd, env, claudeConfigDir: record.accountHome.path, providerSessionId, diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index af6ed824dd2c..7bda9d537997 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -5,6 +5,7 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from '../native-chat/agent-session-journal/journal-payload-bounds' import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema' +import { classifyProviderFrame } from '../native-chat/agent-session-wire/provider-frame-disposition' import { type UnhandledProviderFrameJournalItemOptions, readableProviderFrameText, @@ -34,6 +35,24 @@ export function claudeProviderFrameKind(message: Record): strin return ['message', type, subtype ?? eventType].filter(Boolean).join(':') } +// Telemetry the translator never journals: the token tally Claude sends after every thinking delta, +// stream deltas no stream registry carries (signatures, tool input), and keep-alive pings. +const PROGRESS_FRAME_KINDS: ReadonlySet = new Set([ + 'message:system:thinking_tokens', + 'message:stream_event:content_block_delta', + 'message:stream_event:ping' +]) + +/** A frame that writes no row, so nothing streamed has to be journaled ahead of it. A failure it + * reports still surfaces as a row, so it is not one. */ +export function isClaudeProgressFrame(message: Record): boolean { + const kind = claudeProviderFrameKind(message) + return ( + PROGRESS_FRAME_KINDS.has(kind) && + classifyProviderFrame('claude', kind, message) !== 'error-surface' + ) +} + const SETTLED_RESULT_KINDS: ReadonlySet = new Set( CLAUDE_STREAM_JSON_FRAME_KINDS.filter((kind) => kind.startsWith('message:result:')) ) diff --git a/src/main/claude/claude-structured-reasoning-lifecycle.test.ts b/src/main/claude/claude-structured-reasoning-lifecycle.test.ts new file mode 100644 index 000000000000..22376017acdd --- /dev/null +++ b/src/main/claude/claude-structured-reasoning-lifecycle.test.ts @@ -0,0 +1,213 @@ +// Every way a streamed Claude reasoning row ends when its final frame never comes. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionAppendOptions } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' + +type Write = { + key: string + body: AgentJournalItemBody + options: StructuredAgentSessionAppendOptions +} + +function setup() { + const writes: Write[] = [] + const translator = createClaudeJournalTranslator({ + sink: { + appendItem: (identity, body, options) => + writes.push({ key: agentJournalItemKey(identity), body, options }), + appendTombstone: vi.fn(), + publish: vi.fn() + } + }) + const message = ( + frame: Record, + observedAt: number, + startsTurn = false + ): ClaudeStructuredSessionEvent => ({ + type: 'message', + sessionId: 'orca-session', + observedAt, + ...(startsTurn ? { startsTurn: true } : {}), + message: { session_id: 'claude-session', parent_tool_use_id: null, ...frame } + }) + const thinkingDelta = (uuid: string, thinking: string, observedAt: number, index = 0): void => + translator.handle( + message( + { + type: 'stream_event', + uuid, + event: { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } + }, + observedAt + ) + ) + const reasoningWrites = () => + writes.filter((write) => write.body.kind === 'message' && write.body.role === 'reasoning') + const lastReasoning = () => reasoningWrites().at(-1) + return { writes, translator, message, thinkingDelta, reasoningWrites, lastReasoning } +} + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('a streamed reasoning row the provider never finishes', () => { + it('ends when the turn settles on its result', () => { + const { translator, message, thinkingDelta, lastReasoning } = setup() + thinkingDelta('delta', 'Unfinished thought', 1_000) + translator.handle( + message({ type: 'result', subtype: 'success', uuid: 'result', is_error: false }, 4_000) + ) + expect(lastReasoning()?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'Unfinished thought' }], + state: 'completed', + completedAt: 4_000 + }) + // The end must survive backpressure: nothing later would write it. + expect(lastReasoning()?.options.lifecycle).toBe(true) + expect(translator.pendingStreamedBlocks).toBe(0) + }) + + it('ends when the CLI reports the session idle', () => { + const { translator, message, thinkingDelta, lastReasoning } = setup() + thinkingDelta('delta', 'Unfinished thought', 1_000) + translator.handle( + message( + { type: 'system', subtype: 'session_state_changed', state: 'idle', uuid: 'idle' }, + 6_000 + ) + ) + expect(lastReasoning()?.body).toMatchObject({ state: 'completed', completedAt: 6_000 }) + }) + + it('ends when the child exits', () => { + const { translator, thinkingDelta, lastReasoning } = setup() + thinkingDelta('delta', 'Unfinished thought', 1_000) + translator.handle({ + type: 'ended', + sessionId: 'orca-session', + reason: 'exit', + observedAt: 7_000 + }) + expect(lastReasoning()?.body).toMatchObject({ state: 'completed', completedAt: 7_000 }) + }) + + it('ends when a new send supersedes its turn', () => { + const { translator, message, thinkingDelta, lastReasoning } = setup() + thinkingDelta('delta', 'Unfinished thought', 1_000) + translator.handle( + message( + { + type: 'user', + uuid: 'user-2', + message: { role: 'user', content: [{ type: 'text', text: 'next' }] } + }, + 8_000, + true + ) + ) + expect(lastReasoning()?.body).toMatchObject({ state: 'completed', completedAt: 8_000 }) + }) + + it('ends when its stream starts a new message instead', () => { + const { translator, message, thinkingDelta, reasoningWrites } = setup() + thinkingDelta('delta', 'Abandoned attempt', 1_000) + translator.flush() + const abandoned = reasoningWrites()[0]?.key + translator.handle( + message( + { + type: 'stream_event', + uuid: 'retry', + event: { type: 'message_start', message: { id: 'm-2' } } + }, + 9_000 + ) + ) + const ends = reasoningWrites().filter((write) => write.key === abandoned) + expect(ends.at(-1)?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'Abandoned attempt' }], + state: 'completed', + completedAt: 9_000 + }) + expect(translator.pendingStreamedBlocks).toBe(0) + }) + + it('writes no end for a block that never had text', () => { + const { translator, message, thinkingDelta, reasoningWrites } = setup() + thinkingDelta('delta', '', 1_000) + translator.handle( + message({ type: 'result', subtype: 'success', uuid: 'result', is_error: false }, 4_000) + ) + expect(reasoningWrites()).toEqual([]) + }) + + it('is not rewritten by the token tally Claude sends after every thinking delta', () => { + const { translator, message, thinkingDelta, reasoningWrites } = setup() + for (let index = 0; index < 20; index += 1) { + thinkingDelta(`delta-${index}`, 'more words ', 1_000 + index) + translator.handle( + message( + { + type: 'system', + subtype: 'thinking_tokens', + estimated_tokens: index, + estimated_tokens_delta: 1, + uuid: `tokens-${index}` + }, + 1_000 + index + ) + ) + } + // Text reaches the row on the coalescer's cadence, not once per tally. + expect(reasoningWrites()).toEqual([]) + vi.advanceTimersByTime(100) + expect(reasoningWrites()).toHaveLength(1) + }) + + // Timings of the first block in a captured 2.1.280 session with summarized display. + it('spans from the block start to its final frame, though text arrives seconds later', () => { + const { translator, message, thinkingDelta, reasoningWrites } = setup() + const stream = (uuid: string, event: Record, observedAt: number): void => + translator.handle(message({ type: 'stream_event', uuid, event }, observedAt)) + stream('start', { type: 'message_start', message: { id: 'm-1' } }, 3_400) + stream( + 'block', + { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }, + 3_510 + ) + thinkingDelta('delta', 'Planning the module', 8_085) + translator.handle( + message( + { + type: 'assistant', + uuid: 'final', + message: { + id: 'm-1', + role: 'assistant', + content: [{ type: 'thinking', thinking: 'Planning the module', signature: 's' }] + } + }, + 8_160 + ) + ) + const writes = reasoningWrites() + expect(writes.length).toBeGreaterThan(0) + // Every write names the block's start, so whichever creates the row starts it there. + expect(writes.every((write) => write.options.observedAt === 3_510)).toBe(true) + expect(writes.at(-1)?.body).toMatchObject({ state: 'completed', completedAt: 8_160 }) + }) + + it('writes no row for a stream keep-alive, so nothing lands above the open block', () => { + const { translator, message, thinkingDelta, writes } = setup() + thinkingDelta('delta', 'Planning', 1_000) + translator.handle( + message({ type: 'stream_event', uuid: 'ping', event: { type: 'ping' } }, 1_010) + ) + vi.advanceTimersByTime(100) + expect(writes.filter((write) => write.body.kind === 'status')).toEqual([]) + expect(writes.map((write) => write.body.kind)).toEqual(['turn', 'message']) + }) +}) diff --git a/src/main/claude/claude-structured-reasoning.test.ts b/src/main/claude/claude-structured-reasoning.test.ts new file mode 100644 index 000000000000..662b6af16315 --- /dev/null +++ b/src/main/claude/claude-structured-reasoning.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function setup() { + const rows = new Map() + const translator = createClaudeJournalTranslator({ + sink: { + // Output also opens its turn; these tests read only the content rows. + appendItem: (identity, body) => { + if (body.kind !== 'turn') { + rows.set(agentJournalItemKey(identity), body) + } + }, + appendTombstone: vi.fn(), + publish: vi.fn() + } + }) + const frame = (message: Record, observedAt?: number): void => + translator.handle({ + type: 'message', + sessionId: 'orca-session', + ...(observedAt === undefined ? {} : { observedAt }), + message: { + session_id: 'session', + parent_tool_use_id: null, + ...message + } + }) + const stream = (uuid: string, event: Record, observedAt?: number): void => + frame({ type: 'stream_event', uuid, event }, observedAt) + const final = (uuid: string, content: unknown[], observedAt?: number): void => + frame( + { + type: 'assistant', + uuid, + message: { id: 'message-1', role: 'assistant', content } + }, + observedAt + ) + return { rows, translator, frame, stream, final } +} + +function reasoning(text: string, lifecycle: Record): AgentJournalItemBody { + return { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text }], + ...lifecycle + } +} + +afterEach(() => vi.useRealTimers()) + +describe('structured Claude reasoning', () => { + it.each(['', ' ', '\n\t'])('omits blank final thinking %j', (thinking) => { + const { rows, final, translator } = setup() + final('final-only', [{ type: 'thinking', thinking }]) + expect([...rows.values()]).toEqual([]) + translator.dispose() + }) + + it('writes final-only thinking closed, with no span it never saw', () => { + const { rows, final, translator } = setup() + final('final-only', [{ type: 'thinking', thinking: 'Inspecting the request' }], 5_000) + expect([...rows.values()]).toEqual([ + reasoning('Inspecting the request', { state: 'completed' }) + ]) + translator.dispose() + }) + + it('streams a running row and closes that same row on its final frame', () => { + vi.useFakeTimers() + const { rows, stream, final, translator } = setup() + stream('start', { type: 'message_start', message: { id: 'message-1' } }, 1_000) + stream( + 'thinking-start', + { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '' } }, + 1_000 + ) + stream( + 'delta-1', + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Inspecting ' } + }, + 3_000 + ) + translator.flush() + const firstKey = [...rows.keys()][0] + expect(rows.get(firstKey!)).toEqual(reasoning('Inspecting ', { state: 'running' })) + stream( + 'delta-2', + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'the request' } + }, + 4_000 + ) + translator.flush() + expect([...rows.keys()]).toEqual([firstKey]) + final('thinking-final', [{ type: 'thinking', thinking: 'Inspecting the request' }], 9_000) + expect([...rows.keys()]).toEqual([firstKey]) + expect(rows.get(firstKey!)).toEqual( + reasoning('Inspecting the request', { state: 'completed', completedAt: 9_000 }) + ) + + stream('text-start', { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' } + }) + stream('text-delta', { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'Here is the answer' } + }) + translator.flush() + final('text-final', [{ type: 'text', text: 'Here is the answer' }]) + expect([...rows.values()].map((body) => body.kind === 'message' && body.role)).toEqual([ + 'reasoning', + 'assistant' + ]) + expect(translator.pendingStreamedBlocks).toBe(0) + translator.dispose() + }) + + it('closes a streamed row with its streamed text when the final frame carries none', () => { + vi.useFakeTimers() + const { rows, stream, final, translator } = setup() + stream( + 'delta', + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Summary' } + }, + 1_000 + ) + final('thinking-final', [{ type: 'thinking', thinking: '', signature: 'sig' }], 2_000) + expect([...rows.values()]).toEqual([ + reasoning('Summary', { state: 'completed', completedAt: 2_000 }) + ]) + expect(translator.pendingStreamedBlocks).toBe(0) + translator.dispose() + }) + + it('reconciles an empty final block before the next thinking block', () => { + const { rows, stream, final, translator } = setup() + stream('empty-start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' } + }) + final('empty-final', [{ type: 'thinking', thinking: '' }]) + stream('next-start', { + type: 'content_block_start', + index: 1, + content_block: { type: 'thinking', thinking: 'Next thought' } + }) + translator.flush() + const key = [...rows.keys()][0] + final('next-final', [{ type: 'thinking', thinking: 'Next thought' }]) + expect([...rows.keys()]).toEqual([key]) + expect(translator.pendingStreamedBlocks).toBe(0) + translator.dispose() + }) + + it('writes streamed thinking into the turn its first delta opened', () => { + vi.useFakeTimers() + const scopes: unknown[] = [] + const translator = createClaudeJournalTranslator({ + sink: { + appendItem: (_identity, body, options) => { + if (body.kind === 'message' && body.role === 'reasoning') { + scopes.push(options.turnScope) + } + }, + appendTombstone: vi.fn(), + publish: vi.fn() + } + }) + translator.handle({ + type: 'message', + sessionId: 'orca-session', + message: { + type: 'stream_event', + session_id: 'session', + parent_tool_use_id: null, + uuid: 'delta', + event: { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Considering' } + } + } + }) + translator.flush() + expect(scopes).toEqual([{ kind: 'turn', turnItemId: expect.stringContaining('turn') }]) + translator.dispose() + }) + + it('omits whitespace-only thinking deltas', () => { + vi.useFakeTimers() + const { rows, stream, translator } = setup() + stream('delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: ' \n ' } + }) + translator.flush() + expect(rows.size).toBe(0) + translator.dispose() + }) +}) diff --git a/src/main/claude/claude-thinking-display-support.test.ts b/src/main/claude/claude-thinking-display-support.test.ts new file mode 100644 index 000000000000..a586a65d70a6 --- /dev/null +++ b/src/main/claude/claude-thinking-display-support.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { createClaudeThinkingDisplaySupport } from './claude-thinking-display-support' + +const LAUNCH = { command: '/bin/claude', cwd: '/repo', env: { PATH: '/shims' } } +const UNKNOWN_FLAG = new Error( + "claude stream-json exited (code 1): error: unknown option '--thinking-display'" +) + +type Probe = ( + command: string, + launch: { cwd: string; env: Record; timeoutMs: number } +) => Promise + +function supportWith( + probe: Probe, + budgetMs = 20, + keyOf = async (command: string, cwd: string): Promise => `${command}\n${cwd}` +) { + const calls = vi.fn(probe) + // Real time plus whatever a test skips ahead. + let skippedMs = 0 + const support = createClaudeThinkingDisplaySupport({ + probe: calls, + keyOf, + budgetMs, + now: () => performance.now() + skippedMs + }) + return { support, calls, skip: (ms: number) => (skippedMs += ms) } +} + +/** A probe the test answers by hand. */ +function heldProbe() { + let answer: (version: string | null) => void = () => {} + const probe: Probe = () => + new Promise((resolve) => { + answer = resolve + }) + return { probe, answer: (version: string | null) => answer(version) } +} + +describe('the thinking-display flag a launch passes', () => { + it("probes with the launch's own cwd and env and its own kill timeout, then remembers", async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) + expect(calls).toHaveBeenCalledTimes(1) + expect(calls).toHaveBeenCalledWith('/bin/claude', { + cwd: '/repo', + env: { PATH: '/shims' }, + timeoutMs: 10_000 + }) + }) + + it('asks again per workspace: a shim can pick a different CLI there', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await support.argsFor(LAUNCH) + await support.argsFor({ ...LAUNCH, cwd: '/other' }) + expect(calls).toHaveBeenCalledTimes(2) + }) + + it('passes nothing to a CLI older than the flag, and keeps that answer for good', async () => { + const { support, calls, skip } = supportWith(async () => '2.1.92') + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + skip(60 * 60_000) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('asks again after a while when a probe printed no version, failed or was killed', async () => { + for (const probe of [async () => null, () => Promise.reject(new Error('EMFILE'))]) { + const { support, calls, skip } = supportWith(probe) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + // Within the window a hung or broken probe costs no further spawn or wait. + skip(9 * 60_000) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + expect(calls).toHaveBeenCalledTimes(1) + // A failure from a loaded boot heals. + skip(2 * 60_000) + await support.argsFor(LAUNCH) + expect(calls).toHaveBeenCalledTimes(2) + } + }) + + it('waits for a probe that answers within the budget', async () => { + const { support } = supportWith( + () => new Promise((resolve) => setTimeout(() => resolve('2.1.280'), 5)), + 1_000 + ) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) + }) + + it('does not wait again on a probe already past its budget, and keeps its late answer', async () => { + const held = heldProbe() + const { support, calls } = supportWith(held.probe, 30) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + const started = performance.now() + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + expect(performance.now() - started).toBeLessThan(25) + held.answer('2.1.280') + await vi.waitFor(async () => + expect(await support.argsFor(LAUNCH)).toEqual({ 'thinking-display': 'summarized' }) + ) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('stops passing the flag to a binary that exited refusing it', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + await support.argsFor(LAUNCH) + support.observeExit(LAUNCH, UNKNOWN_FLAG) + await vi.waitFor(async () => expect(await support.argsFor(LAUNCH)).toEqual({})) + expect(calls).toHaveBeenCalledTimes(1) + }) + + it('keeps a refusal seen while a probe was still running', async () => { + const held = heldProbe() + const { support } = supportWith(held.probe, 10) + await support.argsFor(LAUNCH) + support.observeExit(LAUNCH, UNKNOWN_FLAG) + await new Promise((resolve) => setTimeout(resolve, 0)) + held.answer('2.1.280') + await new Promise((resolve) => setTimeout(resolve, 0)) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + }) + + it('records nothing for any other startup failure', async () => { + const { support } = supportWith(async () => '2.1.280') + await support.argsFor(LAUNCH) + support.observeExit(LAUNCH, new Error('claude stream-json exited (code 1): not signed in')) + support.observeExit(LAUNCH, new Error("error: unknown option '--thinking'")) + await new Promise((resolve) => setTimeout(resolve, 0)) + await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) + }) + + it('spends the budget finding the binary too, and caches nothing when that runs out', async () => { + let slow = true + const { support, calls } = supportWith( + async () => '2.1.280', + 30, + async (command, cwd) => { + if (slow) { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + return `${command}\n${cwd}` + } + ) + const started = performance.now() + await expect(support.argsFor(LAUNCH)).resolves.toEqual({}) + expect(performance.now() - started).toBeLessThan(500) + expect(calls).not.toHaveBeenCalled() + slow = false + await expect(support.argsFor(LAUNCH)).resolves.toEqual({ 'thinking-display': 'summarized' }) + }) + + it('forgets the binaries launched least recently, not the ones in use', async () => { + const { support, calls } = supportWith(async () => '2.1.280') + const at = (index: number) => ({ ...LAUNCH, cwd: `/repo-${index}` }) + for (let index = 1; index <= 32; index += 1) { + await support.argsFor(at(index)) + } + await support.argsFor(at(1)) + await support.argsFor(at(33)) + expect(calls).toHaveBeenCalledTimes(33) + await support.argsFor(at(1)) + expect(calls).toHaveBeenCalledTimes(33) + await support.argsFor(at(2)) + expect(calls).toHaveBeenCalledTimes(34) + }) +}) diff --git a/src/main/claude/claude-thinking-display-support.ts b/src/main/claude/claude-thinking-display-support.ts new file mode 100644 index 000000000000..f7ad53d44339 --- /dev/null +++ b/src/main/claude/claude-thinking-display-support.ts @@ -0,0 +1,175 @@ +import { realpath, stat } from 'node:fs/promises' +import { claudeVersionReaches, probeClaudeCliVersion } from './claude-hook-event-versions' + +// Why: the first CLI whose parser defines the flag (2.1.93 was never published); an older one exits +// on it before the session starts. Found by reading published packages, not by running them. +const CLAUDE_THINKING_DISPLAY_FIRST_VERSION = '2.1.94' + +/** How long a launch waits on a binary nothing is known about yet. A warm probe answers in tens of + * milliseconds; this covers a cold disk, a node install and an antivirus scan, paid once per key + * during a start that already takes seconds. */ +export const CLAUDE_THINKING_DISPLAY_PROBE_BUDGET_MS = 1_500 + +/** A probe still running by now is killed, and its binary gets no flag. */ +const PROBE_KILL_AFTER_MS = 10_000 + +/** How long a probe that gave no version (killed, failed to spawn, unparseable) means "no flag": + * long enough that a hung `--version` costs one wait per stretch, short enough that a failure + * from a loaded boot heals. */ +const FAILED_PROBE_RETRY_AFTER_MS = 10 * 60_000 + +/** Commander's refusal, exactly: any other startup failure says nothing about the flag. */ +const UNKNOWN_FLAG_DIAGNOSTIC = "unknown option '--thinking-display'" + +// One small entry per binary per workspace it launched in; enough for every worktree in active use. +const MAX_REMEMBERED = 32 + +const SUMMARIZED: Readonly> = { 'thinking-display': 'summarized' } + +export type ClaudeThinkingDisplayLaunch = { + command: string + cwd: string + env: Record +} + +type ClaudeVersionProbe = ( + command: string, + launch: { cwd: string; env: Record; timeoutMs: number } +) => Promise + +export type ClaudeThinkingDisplaySupport = { + /** + * Asks for readable thinking: under Orca's launch the CLI otherwise streams thinking blocks with + * no text. Only the display is set, never `--thinking`, so a user who turned thinking off keeps + * it off. A binary not yet known is probed with the launch's own cwd and env, waited on for at + * most the budget from when its probe began; past it the launch goes without the flag. + */ + argsFor: (launch: ClaudeThinkingDisplayLaunch) => Promise>> + /** A child that exited refusing the flag: that binary, in that workspace, never gets it again. */ + observeExit: (launch: Pick, error: Error) => void +} + +/** Which binary a command is in a workspace right now: a shim answers per project, and a + * self-update swaps the link's target or the file. */ +async function claudeBinaryKey(command: string, cwd: string): Promise { + try { + const target = await realpath(command) + return `${target}\n${(await stat(target)).mtimeMs}\n${cwd}` + } catch { + return null + } +} + +/** The promise's value if it settles within `ms`, else undefined. */ +async function within(pending: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const expired = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), Math.max(0, ms)) + timer.unref?.() + }) + try { + return await Promise.race([pending, expired]) + } finally { + clearTimeout(timer) + } +} + +export function createClaudeThinkingDisplaySupport( + deps: { + probe: ClaudeVersionProbe + keyOf: (command: string, cwd: string) => Promise + budgetMs: number + now: () => number + } = { + probe: probeClaudeCliVersion, + keyOf: claudeBinaryKey, + budgetMs: CLAUDE_THINKING_DISPLAY_PROBE_BUDGET_MS, + now: () => performance.now() + } +): ClaudeThinkingDisplaySupport { + /** `expiresAt` only on an answer that was no answer: a version or a refusal is final. */ + const known = new Map() + const probing = new Map; startedAt: number }>() + const remember = (key: string, supported: boolean, expiresAt?: number): void => { + known.delete(key) + known.set(key, { supported, ...(expiresAt === undefined ? {} : { expiresAt }) }) + for (const stale of known.keys()) { + if (known.size <= MAX_REMEMBERED) { + break + } + known.delete(stale) + } + } + // A version is kept for the binary's life. A probe that gave none is kept only for a while, so + // a hung CLI costs one wait per stretch and a boot-time failure heals. A refusal seen meanwhile + // wins over either. + const settle = (key: string, supported: boolean, expiresAt?: number): void => { + if (!known.has(key)) { + remember(key, supported, expiresAt) + } + } + const settleWithoutVersion = (key: string): void => + settle(key, false, deps.now() + FAILED_PROBE_RETRY_AFTER_MS) + const lookup = (key: string): boolean | undefined => { + const entry = known.get(key) + if (entry?.expiresAt !== undefined && entry.expiresAt <= deps.now()) { + known.delete(key) + return undefined + } + if (entry) { + // Read as used: the bound drops the binaries launched least recently. + remember(key, entry.supported, entry.expiresAt) + } + return entry?.supported + } + const probe = (key: string, launch: ClaudeThinkingDisplayLaunch) => { + const settled = deps + .probe(launch.command, { cwd: launch.cwd, env: launch.env, timeoutMs: PROBE_KILL_AFTER_MS }) + .then( + (version) => + version === null + ? settleWithoutVersion(key) + : settle(key, claudeVersionReaches(version, CLAUDE_THINKING_DISPLAY_FIRST_VERSION)), + () => settleWithoutVersion(key) + ) + .finally(() => probing.delete(key)) + const started = { settled, startedAt: deps.now() } + probing.set(key, started) + return started + } + + return { + argsFor: async (launch) => { + // The launch never waits longer than the budget, finding the binary included. + const deadline = deps.now() + deps.budgetMs + const key = await within(deps.keyOf(launch.command, launch.cwd), deps.budgetMs) + if (key === undefined || key === null) { + return {} + } + const supported = lookup(key) + if (supported !== undefined) { + return supported ? SUMMARIZED : {} + } + const running = probing.get(key) ?? probe(key, launch) + // The probe's own budget, too: one already past it is not waited on again. + await within( + running.settled, + Math.min(running.startedAt + deps.budgetMs, deadline) - deps.now() + ) + return known.get(key)?.supported === true ? SUMMARIZED : {} + }, + observeExit: (launch, error) => { + if (!error.message.includes(UNKNOWN_FLAG_DIAGNOSTIC)) { + return + } + void deps.keyOf(launch.command, launch.cwd).then((key) => { + if (key !== null) { + remember(key, false) + } + }) + } + } +} + +/** One per process: every structured launch on this host shares what it learned. */ +export const claudeThinkingDisplaySupport = createClaudeThinkingDisplaySupport() diff --git a/src/main/codex/codex-persistent-command-retention.test.ts b/src/main/codex/codex-persistent-command-retention.test.ts index 0f410c6b362d..9da3e7792077 100644 --- a/src/main/codex/codex-persistent-command-retention.test.ts +++ b/src/main/codex/codex-persistent-command-retention.test.ts @@ -117,6 +117,7 @@ describe('persistent command retention', () => { threadId: `thread-${thread}`, turnId: 'turn', turnLifecycle: null, + completedAt: 1, turnEnd: 'completed', sink, streams: items.streams, @@ -214,6 +215,7 @@ describe('persistent command retention', () => { threadId: 'root', turnId: 'turn', turnLifecycle: null, + completedAt: 1, turnEnd: 'completed', sink, streams: items.streams, diff --git a/src/main/codex/codex-structured-item-stream-bounds.ts b/src/main/codex/codex-structured-item-stream-bounds.ts index 46104c54360b..8a7a1f1147f1 100644 --- a/src/main/codex/codex-structured-item-stream-bounds.ts +++ b/src/main/codex/codex-structured-item-stream-bounds.ts @@ -1,4 +1,7 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' import { toolExecutionMetadata } from '../../shared/native-chat-tool-identity' +import type { CodexItemStreamState } from './codex-structured-item-stream-contracts' +import type { CodexThreadItem } from './codex-thread-item-identity' export const MAX_CODEX_ITEM_STREAM_STATES = 256 export const MAX_CODEX_ITEM_STREAM_PENDING_PATCHES = 128 @@ -37,3 +40,16 @@ export function boundStreamItem(item: Record): Record boolean handle: ( threadId: string, diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts index 33b3fa7b3cb9..291ff544c528 100644 --- a/src/main/codex/codex-structured-item-streams.ts +++ b/src/main/codex/codex-structured-item-streams.ts @@ -5,17 +5,14 @@ import { import { createAgentSessionDeltaCoalescer } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' import { CodexItemStreamRetention } from './codex-item-stream-retention' import { appendCodexItemAndPublish } from './codex-structured-journal-sink' -import { - codexJournalItem, - codexStreamingJournalItem, - type CodexThreadItem -} from './codex-structured-item-translation' +import { codexJournalItem, codexStreamingJournalItem } from './codex-structured-item-translation' +import { withJournalReasoningLifecycle } from '../native-chat/agent-session-journal/journal-reasoning-row' import { codexStructuredItemKey, MAX_CODEX_ITEM_STREAM_PENDING_PATCHES, MAX_CODEX_ITEM_STREAM_PENDING_PATCH_BYTES, MAX_CODEX_ITEM_STREAM_RETAINED_BYTES, - boundStreamItem, + codexItemStreamState, pendingPatchBytes } from './codex-structured-item-stream-bounds' import { @@ -117,8 +114,12 @@ export function createCodexStructuredItemStreams( if (!translated.body) { return true } - return appendCodexItemAndPublish(deps.sink, state.identity, translated.body, attributionOf(key)) - .accepted + // A stream only ever carries an item that has not completed yet. + const body = withJournalReasoningLifecycle(translated.body, { state: 'running' }) + return appendCodexItemAndPublish(deps.sink, state.identity, body, { + ...attributionOf(key), + ...(state.startedAt === undefined ? {} : { observedAt: state.startedAt }) + }).accepted } const persist = (key: string, text: string, force: boolean): boolean => { @@ -209,13 +210,13 @@ export function createCodexStructuredItemStreams( return states.persistentSize }, canTrack: (threadId, item, identity) => - states.canRetain(codexStructuredItemKey(threadId, item.id), { - item: boundStreamItem(item) as CodexThreadItem, - identity - }), - track: (threadId, turnId, item, identity) => { + states.canRetain( + codexStructuredItemKey(threadId, item.id), + codexItemStreamState(item, identity) + ), + track: (threadId, turnId, item, identity, startedAt) => { const key = codexStructuredItemKey(threadId, item.id) - if (!states.retain(key, { item: boundStreamItem(item) as CodexThreadItem, identity })) { + if (!states.retain(key, codexItemStreamState(item, identity, startedAt))) { return false } producers.set(key, { threadId, turnId }) diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index ad63d624f58b..45a6ca6e9dfd 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -851,6 +851,34 @@ describe('codex item bodies', () => { }) }) + it('keeps streamed reasoning as a message and leaves streamed plans as status', () => { + expect(codexStreamingJournalItem({ type: 'reasoning', id: 'r' }, 'thinking')).toEqual({ + handled: true, + body: { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'thinking' }] + } + }) + expect(codexStreamingJournalItem({ type: 'reasoning', id: 'r' }, ' \n ')).toEqual({ + handled: true, + body: null + }) + expect(codexStreamingJournalItem({ type: 'plan', id: 'p' }, 'First\nSecond')).toEqual({ + handled: true, + body: { kind: 'status', text: 'First\nSecond', presentation: 'plan-document' } + }) + }) + + it('omits blank reasoning and preserves the plan document body', () => { + expect(codexItemBody({ type: 'reasoning', id: 'r', text: ' \n ' })).toBeNull() + expect(codexItemBody({ type: 'plan', id: 'p', text: 'First\nSecond' })).toEqual({ + kind: 'status', + text: 'First\nSecond', + presentation: 'plan-document' + }) + }) + it('renders array-shaped reasoning content', () => { expect( codexItemBody({ diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts index 25b821d90d9f..50ca92b80eeb 100644 --- a/src/main/codex/codex-structured-item-translation.ts +++ b/src/main/codex/codex-structured-item-translation.ts @@ -32,6 +32,7 @@ export { MAX_CODEX_TURN_ORDINAL_BYTES, MAX_CODEX_TURN_ORDINAL_ENTRIES } from './codex-turn-ordinals' +import { journalReasoningBody } from '../native-chat/agent-session-journal/journal-reasoning-row' // Codex thread items → journal item bodies. @@ -76,10 +77,6 @@ export type CodexJournalItem = { handled: boolean } -function reasoningMessageBody(text: string): AgentJournalItemBody { - return { kind: 'message', role: 'reasoning', blocks: [{ type: 'text', text }] } -} - function commandItem(item: CodexThreadItem): CodexJournalItem { const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output']) const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) @@ -281,13 +278,7 @@ export function codexJournalItem( readTextContent(item, 'text') ?? readTextContent(item, 'summary') ?? readTextContent(item, 'content') - return { - body: - text === null - ? null - : reasoningMessageBody(boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text), - handled: true - } + return { body: journalReasoningBody(text), handled: true } } const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item) return unhandled ? { body: unhandled.body, handled: false } : { body: null, handled: true } @@ -311,6 +302,9 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): if (item.type === 'agentMessage') { return { body: codexStreamingMessageBody(text), handled: true } } + if (item.type === 'reasoning') { + return { body: journalReasoningBody(text), handled: true } + } if (item.type === 'commandExecution') { return commandItem({ ...item, aggregatedOutput: text }) } @@ -334,12 +328,8 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string): handled: true } } - const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) return { - body: - item.type === 'reasoning' - ? reasoningMessageBody(bounded.text) - : { kind: 'status', text: bounded.text }, + body: { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, handled: true } } diff --git a/src/main/codex/codex-structured-journal-contracts.ts b/src/main/codex/codex-structured-journal-contracts.ts index af7c1ed3035d..b79ac93c3fc3 100644 --- a/src/main/codex/codex-structured-journal-contracts.ts +++ b/src/main/codex/codex-structured-journal-contracts.ts @@ -4,8 +4,21 @@ import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-sessio import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import type { CodexSubagentExecutions } from './codex-subagent-executions' +import type { CodexThreadItem } from './codex-structured-item-translation' +import type { CodexHelperName } from './codex-collab-agent-item-translation' import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +export type CodexActiveJournalItem = { + threadId: string + turnId: string | null + identity: AgentJournalItemIdentity + item: CodexThreadItem + /** Names the helpers a collab call acted on, so a settled revision keeps naming them. */ + helperName?: CodexHelperName + /** Host clock at item/started, for a row whose first write comes later. */ + startedAt?: number +} + export type CodexJournalTranslatorDeps = { sink: StructuredAgentSessionEventSink /** Names this connection in frame-row identities, so a later connection never revises its rows. */ diff --git a/src/main/codex/codex-structured-journal-items.ts b/src/main/codex/codex-structured-journal-items.ts index ec6ac67df5d4..17a002225e7c 100644 --- a/src/main/codex/codex-structured-journal-items.ts +++ b/src/main/codex/codex-structured-journal-items.ts @@ -1,5 +1,4 @@ import type { - AgentJournalItemBody, AgentJournalItemIdentity, AgentJournalRowAttribution } from '../../shared/agent-session-journal-types' @@ -16,6 +15,7 @@ import type { CodexHelperName } from './codex-collab-agent-item-translation' import { boundStreamItem, codexStructuredItemKey } from './codex-structured-item-stream-bounds' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import type { + CodexActiveJournalItem, CodexItemTranslation, CodexJournalTranslationAdmission, CodexJournalTranslatorDeps @@ -28,10 +28,19 @@ import { MAX_CODEX_IDENTITY_ENTRIES } from './codex-structured-journal-limits' import { appendCodexLifecycleItem, publishCodexLifecycle } from './codex-structured-journal-sink' -import type { CodexActiveJournalItem } from './codex-structured-journal-settlement' import { readCodexJournalString } from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' import { readCodexDispatchEcho } from './codex-structured-dispatch-echo' +import { + codexActiveItemBody, + codexCompletedItem, + interruptedCodexItemBody +} from './codex-unfinished-item-body' +import { + endedJournalReasoning, + withJournalReasoningLifecycle, + type JournalReasoningLifecycle +} from '../native-chat/agent-session-journal/journal-reasoning-row' import type { CodexRowAttribution } from './codex-subagent-linkage' export class CodexJournalItems { @@ -44,7 +53,7 @@ export class CodexJournalItems { constructor( private readonly deps: Pick< CodexJournalTranslatorDeps, - 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' + 'sink' | 'coalesceMs' | 'maxRetainedBytes' | 'schedule' | 'now' > & { maxMetadataBytes?: number; attributionFor: CodexRowAttribution }, private readonly activeTurn: (threadId: string) => string | null, private readonly suppress: (threadId: string, turnId: string) => void, @@ -67,7 +76,7 @@ export class CodexJournalItems { } handle( - event: { threadId: string; method: string; params: unknown }, + event: { threadId: string; method: string; params: unknown; observedAt?: number }, source: 'live' | 'history' = 'live' ): CodexItemTranslation { const params = @@ -99,9 +108,23 @@ export class CodexJournalItems { return { handled: true, admission: { accepted: false, reason: 'failed' } } } const itemKey = codexStructuredItemKey(event.threadId, item.id) - const started = - event.method === 'item/completed' ? this.activeItems.get(itemKey)?.item : undefined - const translated = codexJournalItem(item, this.helperName, started) + const active = event.method === 'item/completed' ? this.activeItems.get(itemKey) : undefined + const receivedAt = event.observedAt ?? this.deps.now?.() ?? Date.now() + const lifecycle: JournalReasoningLifecycle = + source === 'history' + ? endedJournalReasoning() + : event.method === 'item/completed' + ? // A completion with no start on record claims no span it never saw. + endedJournalReasoning(active?.startedAt === undefined ? undefined : receivedAt) + : { state: 'running' } + const translated = withItemLifecycle( + codexCompletedItem( + codexJournalItem(item, this.helperName, active?.item), + active, + this.streams + ), + lifecycle + ) const command = readCodexJournalString(item, 'command') if (command) { const boundedCommand = Buffer.from(command, 'utf8') @@ -114,20 +137,22 @@ export class CodexJournalItems { this.streams.forget(event.threadId, item.id) this.activeItems.delete(itemKey) } else { - this.track(event.threadId, turnId, item, identity) - const admission = this.trimActiveState() + const admission = this.trimActiveState(this.growsActiveSet(itemKey, item)) if (!admission.accepted) { return { handled: true, admission } } + // An item whose row waits for its first text still started here. + this.track(event.threadId, turnId, item, identity, receivedAt) } return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } - const admission = this.appendTranslated( - event.method, - identity, - translated, - this.deps.attributionFor(event.threadId, turnId) - ) + // Whichever write creates the row, the row starts with its item: a completion can be the first + // write when its text came within one coalescing window. + const startedAt = event.method === 'item/completed' ? active?.startedAt : receivedAt + const admission = this.appendTranslated(event.method, identity, translated, { + ...this.deps.attributionFor(event.threadId, turnId), + ...(startedAt === undefined ? {} : { observedAt: startedAt }) + }) if (!admission.accepted) { return { handled: true, admission } } @@ -135,11 +160,11 @@ export class CodexJournalItems { this.streams.forget(event.threadId, item.id) this.activeItems.delete(itemKey) } else { - this.track(event.threadId, turnId, item, identity) - const trimAdmission = this.trimActiveState() + const trimAdmission = this.trimActiveState(this.growsActiveSet(itemKey, item)) if (!trimAdmission.accepted) { return { handled: true, admission: trimAdmission } } + this.track(event.threadId, turnId, item, identity, receivedAt) } return { handled: true, admission: CODEX_JOURNAL_ADMITTED } } @@ -155,7 +180,7 @@ export class CodexJournalItems { method: string, identity: AgentJournalItemIdentity, translated: ReturnType, - attribution: AgentJournalRowAttribution + attribution: AgentJournalRowAttribution & { observedAt?: number } ): CodexJournalTranslationAdmission { if (!translated.body) { return CODEX_JOURNAL_ADMITTED @@ -187,17 +212,19 @@ export class CodexJournalItems { threadId: string, turnId: string | null, item: CodexThreadItem, - identity: AgentJournalItemIdentity + identity: AgentJournalItemIdentity, + startedAt?: number ): void { const retainedItem = codexCommandOutlivesTurn(item) ? (boundStreamItem(item) as CodexThreadItem) : item - this.streams.track(threadId, turnId, retainedItem, identity) + this.streams.track(threadId, turnId, retainedItem, identity, startedAt) this.activeItems.set(codexStructuredItemKey(threadId, item.id), { threadId, turnId, identity, item: retainedItem, + ...(startedAt === undefined ? {} : { startedAt }), ...(this.helperName ? { helperName: this.helperName } : {}) }) } @@ -229,8 +256,15 @@ export class CodexJournalItems { return identity } - private trimActiveState(): CodexJournalTranslationAdmission { - while (this.activeItems.size - this.streams.persistentCount > MAX_CODEX_ACTIVE_ITEMS) { + private growsActiveSet(itemKey: string, item: CodexThreadItem): boolean { + return !this.activeItems.has(itemKey) && !codexCommandOutlivesTurn(item) + } + + /** Makes room for an item before it is tracked: tracking it first lets the stream bound drop an + * evictee's text before this eviction can close the evictee's row with it. */ + private trimActiveState(incoming: boolean): CodexJournalTranslationAdmission { + const room = MAX_CODEX_ACTIVE_ITEMS - (incoming ? 1 : 0) + while (this.activeItems.size - this.streams.persistentCount > room) { const oldest = [...this.activeItems].find( ([, active]) => !codexCommandOutlivesTurn(active.item) )?.[0] @@ -239,14 +273,12 @@ export class CodexJournalItems { } const evicted = this.activeItems.get(oldest) if (evicted) { - const translated = codexJournalItem(evicted.item, this.helperName).body - if (translated) { - const admission = appendCodexLifecycleItem( - this.deps.sink, - evicted.identity, - evictedActiveBody(translated), - this.deps.attributionFor(evicted.threadId, evicted.turnId) - ) + const body = interruptedCodexItemBody(codexActiveItemBody(evicted, this.streams)) + if (body) { + const admission = appendCodexLifecycleItem(this.deps.sink, evicted.identity, body, { + ...this.deps.attributionFor(evicted.threadId, evicted.turnId), + ...(evicted.startedAt === undefined ? {} : { observedAt: evicted.startedAt }) + }) if (!admission.accepted) { return admission } @@ -264,23 +296,11 @@ export class CodexJournalItems { } } -function evictedActiveBody(body: AgentJournalItemBody): AgentJournalItemBody { - if (body.kind === 'tool-call' && body.state === 'running') { - return { ...body, state: 'failed' } - } - if ( - (body.kind === 'approval' || body.kind === 'question') && - body.resolution.state === 'pending' - ) { - return { - ...body, - resolution: { - state: 'cancelled', - selectedOptionId: null, - resolvedBy: null, - resolvedAt: null - } - } - } - return body +function withItemLifecycle( + translated: ReturnType, + lifecycle: JournalReasoningLifecycle +): ReturnType { + return translated.body + ? { ...translated, body: withJournalReasoningLifecycle(translated.body, lifecycle) } + : translated } diff --git a/src/main/codex/codex-structured-journal-settlement.ts b/src/main/codex/codex-structured-journal-settlement.ts index e24cbd87037c..857e82dc33f8 100644 --- a/src/main/codex/codex-structured-journal-settlement.ts +++ b/src/main/codex/codex-structured-journal-settlement.ts @@ -1,4 +1,3 @@ -import { endedRunningAgentJournalToolCall } from '../../shared/agent-journal-tool-call-lifecycle' import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody, @@ -15,14 +14,8 @@ import type { StructuredAgentSessionSinkAdmission } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' -import { - codexJournalItem, - codexStreamingJournalItem, - type CodexThreadItem, - type CodexTurnOrdinals -} from './codex-structured-item-translation' +import type { CodexTurnOrdinals } from './codex-structured-item-translation' import type { CodexStructuredItemStreams } from './codex-structured-item-streams' -import type { CodexHelperName } from './codex-collab-agent-item-translation' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' import { codexCommandOutlivesTurn } from './codex-command-lifecycle' import { @@ -30,16 +23,9 @@ import { codexTurnLifecycleIdentity } from './codex-structured-journal-translation-turns' import { appendCodexLifecycleMutations } from './codex-structured-journal-sink' +import { codexActiveItemBody, interruptedCodexItemBody } from './codex-unfinished-item-body' import type { CodexRowAttribution } from './codex-subagent-linkage' - -export type CodexActiveJournalItem = { - threadId: string - turnId: string | null - identity: AgentJournalItemIdentity - item: CodexThreadItem - /** Names the helpers a collab call acted on, so a settled revision keeps naming them. */ - helperName?: CodexHelperName -} +import type { CodexActiveJournalItem } from './codex-structured-journal-contracts' export type CodexPendingJournalPrompt = { threadId: string @@ -63,17 +49,17 @@ export function settleCodexJournalSession(input: { * conversation command claimed, whose record the host settles. */ settledTurnLifecycle: (threadId: string, turnId: string) => AgentJournalTurnLifecycle | null attributionFor: CodexRowAttribution + now?: () => number }): StructuredAgentSessionSinkAdmission { // Rows from every thread settle in this one batch, so each names its own producer. const mutations: JournalLifecycleMutationInput[] = [] const turnOrdinalsToForget: { threadId: string; turnId: string }[] = [] for (const active of input.activeItems.values()) { - const streamed = input.streams.snapshot(active.threadId, active.item.id) - const translated = streamed - ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item, active.helperName) // The host saw the child go, so its work was cut short. - const body = settledActiveBody(translated.body, 'interrupted') + const body = interruptedCodexItemBody(codexActiveItemBody(active, input.streams), { + at: input.event.observedAt ?? input.now?.() ?? Date.now(), + call: 'interrupted' + }) if (body) { mutations.push(settledRow(input.attributionFor, active, body)) } @@ -121,6 +107,8 @@ export function settleCodexJournalTurn(input: { turnId: string /** Null off the primary thread: only the primary turn owns a lifecycle row. */ turnLifecycle: AgentJournalTurnLifecycle | null + /** Host clock when the turn's end arrived, which is also the end of anything it left open. */ + completedAt: number /** How Codex ended the turn, on every thread: what a call it left running became. */ turnEnd: Extract sink: StructuredAgentSessionEventSink @@ -143,11 +131,10 @@ export function settleCodexJournalTurn(input: { if (codexCommandOutlivesTurn(active.item)) { continue } - const streamed = input.streams.snapshot(active.threadId, active.item.id) - const translated = streamed - ? codexStreamingJournalItem(active.item, streamed.text) - : codexJournalItem(active.item, active.helperName) - const body = settledActiveBody(translated.body, input.turnEnd) + const body = interruptedCodexItemBody(codexActiveItemBody(active, input.streams), { + at: input.completedAt, + call: input.turnEnd + }) if (body) { mutations.push(settledRow(input.attributionFor, active, body)) } @@ -201,24 +188,6 @@ function settledRow( return journalLifecycleItemMutation(attributionFor(row.threadId, row.turnId), row.identity, body) } -function settledActiveBody( - body: AgentJournalItemBody | null, - turnEnd: 'completed' | 'interrupted' -): AgentJournalItemBody | null { - if (!body) { - return null - } - if (body.kind === 'tool-call') { - return endedRunningAgentJournalToolCall(body, turnEnd) - } - if (body.kind === 'message') { - return body - } - return body.kind === 'diff' - ? { kind: 'status', text: 'File changes were interrupted before completion.' } - : body -} - function exitSettlementId(event: Extract): string { const fence = 'fence' in event ? event.fence : 0 const generation = 'acquisitionGeneration' in event ? event.acquisitionGeneration : 'legacy' diff --git a/src/main/codex/codex-structured-journal-sink.ts b/src/main/codex/codex-structured-journal-sink.ts index 0823e5679cf5..dc4778ea3501 100644 --- a/src/main/codex/codex-structured-journal-sink.ts +++ b/src/main/codex/codex-structured-journal-sink.ts @@ -99,7 +99,8 @@ export function appendCodexLifecycleItem( sink: StructuredAgentSessionEventSink, identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - attribution: AgentJournalRowAttribution + /** With the host time a row first written here should carry instead of its append time. */ + attribution: AgentJournalRowAttribution & { observedAt?: number } ): CodexJournalTranslationAdmission { const options = { lifecycle: true, ...attribution } if (sink.tryAppendItem) { diff --git a/src/main/codex/codex-structured-journal-translation-settlement.test.ts b/src/main/codex/codex-structured-journal-translation-settlement.test.ts index 6c08f01bc49b..a03ee84e3074 100644 --- a/src/main/codex/codex-structured-journal-translation-settlement.test.ts +++ b/src/main/codex/codex-structured-journal-translation-settlement.test.ts @@ -834,7 +834,8 @@ describe('codex journal translation', () => { expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({ kind: 'message', role: 'reasoning', - blocks: [{ type: 'text', text: 'thinking' }] + blocks: [{ type: 'text', text: 'thinking' }], + state: 'running' }) expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ kind: 'diff', diff --git a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts index 5c930eb9f927..d9c52fa181ab 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-boundaries.ts @@ -193,6 +193,7 @@ export class CodexJournalTurnBoundaries { threadId: event.threadId, turnId, turnLifecycle, + completedAt, turnEnd: codexTurnLifecycleState(status), streams: this.deps.items.streams, activeItems: this.deps.items.activeItems, diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index 20fd8bf58df3..19fee37a5368 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -167,7 +167,8 @@ export function createCodexJournalTranslator( completedAt: event.observedAt ?? deps.now?.() ?? Date.now() }) : null, - attributionFor + attributionFor, + ...(deps.now ? { now: deps.now } : {}) }) if (!admission.accepted) { return admission diff --git a/src/main/codex/codex-structured-reasoning-lifecycle.test.ts b/src/main/codex/codex-structured-reasoning-lifecycle.test.ts new file mode 100644 index 000000000000..38fbc2f876bc --- /dev/null +++ b/src/main/codex/codex-structured-reasoning-lifecycle.test.ts @@ -0,0 +1,267 @@ +// Every way a Codex reasoning row opens and ends. +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexJournalItems } from './codex-structured-journal-items' +import { MAX_CODEX_ACTIVE_ITEMS } from './codex-structured-journal-limits' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' +const ROW = 'orca:codex-item%3Athread-abc%3Ar-1' + +function recorder() { + const rows = new Map() + /** The host time each row's first write asked to be stamped with. */ + const firstObservedAt = new Map() + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity, body, options) => { + const key = agentJournalItemKey(identity) + if (!rows.has(key)) { + firstObservedAt.set(key, options.observedAt) + } + rows.set(key, body) + }, + appendTombstone: () => {}, + publish: () => {} + } + return { rows, sink, firstObservedAt } +} + +function notification( + method: string, + params: unknown, + observedAt?: number +): CodexStructuredSessionEvent { + return { + type: 'notification', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method, + params, + ...(observedAt === undefined ? {} : { observedAt }) + } +} + +function reasoning(id: string, summary: string[] = []) { + return { item: { type: 'reasoning', id, summary, content: [] } } +} + +function streamingTurn() { + const { rows, sink, firstObservedAt } = recorder() + const translator = createCodexJournalTranslator({ + sink, + primaryThreadId: () => THREAD_ID, + sessionId: SESSION_ID, + // Deltas are written as they arrive, so the open row is visible without a timer. + schedule: (run) => { + run() + return () => {} + } + }) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + translator.handle(notification('item/started', reasoning('r-1'), 2_000)) + // Production times only turn and item boundaries at receipt; a delta carries no time. + translator.handle( + notification('item/reasoning/summaryTextDelta', { itemId: 'r-1', delta: 'Planning' }) + ) + return { rows, translator, firstObservedAt } +} + +/** A turn whose deltas wait out the coalescing window, as they do in production. */ +function coalescedTurn(now?: () => number) { + const { rows, sink, firstObservedAt } = recorder() + const translator = createCodexJournalTranslator({ + sink, + primaryThreadId: () => THREAD_ID, + sessionId: SESSION_ID, + schedule: () => () => {}, + ...(now ? { now } : {}) + }) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + return { rows, translator, firstObservedAt } +} + +describe('a Codex reasoning row', () => { + it('writes no row while its item has no text, and opens with its first summary text', () => { + const { rows, sink } = recorder() + const translator = createCodexJournalTranslator({ sink, primaryThreadId: () => THREAD_ID }) + translator.handle(notification('turn/started', { turn: { id: TURN_ID } }, 1_000)) + translator.handle(notification('item/started', reasoning('r-1'), 2_000)) + expect(rows.get(ROW)).toBeUndefined() + const streamed = streamingTurn() + expect(streamed.rows.get(ROW)).toMatchObject({ state: 'running' }) + }) + + it('starts when its item started, though its first text came later', () => { + const { firstObservedAt } = streamingTurn() + expect(firstObservedAt.get(ROW)).toBe(2_000) + }) + + // Captured: summary text lands 13–50 ms before item/completed, inside one coalescing window. + it('starts at item/started when its first write is the completion itself', () => { + const streamed = coalescedTurn() + streamed.translator.handle(notification('item/started', reasoning('r-1'), 2_000)) + streamed.translator.handle( + notification('item/reasoning/summaryTextDelta', { itemId: 'r-1', delta: 'Planning' }) + ) + streamed.translator.handle( + notification('item/completed', reasoning('r-1', ['Planning']), 7_650) + ) + expect(streamed.firstObservedAt.get(ROW)).toBe(2_000) + expect(streamed.rows.get(ROW)).toMatchObject({ state: 'completed', completedAt: 7_650 }) + + const completedOnly = coalescedTurn() + completedOnly.translator.handle(notification('item/started', reasoning('r-1'), 2_000)) + completedOnly.translator.handle( + notification('item/completed', reasoning('r-1', ['Planning']), 7_650) + ) + expect(completedOnly.firstObservedAt.get(ROW)).toBe(2_000) + }) + + it('times its start and end on the host clock when a boundary arrives without a time', () => { + let now = 2_000 + const { translator, rows, firstObservedAt } = coalescedTurn(() => now) + translator.handle(notification('item/started', reasoning('r-1'))) + now = 6_000 + translator.handle(notification('item/completed', reasoning('r-1', ['Planning']))) + expect(firstObservedAt.get(ROW)).toBe(2_000) + expect(rows.get(ROW)).toMatchObject({ completedAt: 6_000 }) + }) + + it('ends when its item completes, at the completion it saw', () => { + const { rows, translator } = streamingTurn() + translator.handle(notification('item/completed', reasoning('r-1', ['Planning']), 5_000)) + expect(rows.get(ROW)).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Planning' }], + state: 'completed', + completedAt: 5_000 + }) + }) + + it('ends with its turn when the item never completes', () => { + const { rows, translator } = streamingTurn() + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }, 6_000)) + expect(rows.get(ROW)).toMatchObject({ state: 'completed', completedAt: 6_000 }) + }) + + it('ends when the provider exits mid-item', () => { + const { rows, translator } = streamingTurn() + translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'exit', observedAt: 7_000 }) + expect(rows.get(ROW)).toMatchObject({ state: 'completed', completedAt: 7_000 }) + }) + + it('replays from history as ended, with no span it never saw', () => { + const { rows, sink } = recorder() + const items = new CodexJournalItems( + { sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) }, + () => TURN_ID, + () => {} + ) + items.handle( + { threadId: THREAD_ID, method: 'item/completed', params: reasoning('r-1', ['Planned']) }, + 'history' + ) + expect(rows.get(ROW)).toMatchObject({ state: 'completed' }) + expect(rows.get(ROW)).not.toHaveProperty('completedAt') + }) + + it('ends with its streamed text and no claimed time when the bounded live set drops it', () => { + const { rows, sink } = recorder() + const items = new CodexJournalItems( + { + sink, + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + schedule: () => () => {} + }, + () => TURN_ID, + () => {} + ) + // As captured: a started reasoning item carries an empty summary; its text only streams. + items.handle({ threadId: THREAD_ID, method: 'item/started', params: reasoning('r-1') }) + items.streams.handle(THREAD_ID, 'item/reasoning/summaryTextDelta', { + itemId: 'r-1', + delta: 'Thinking' + }) + expect(rows.get(ROW)).toBeUndefined() + for (let index = 2; index <= MAX_CODEX_ACTIVE_ITEMS + 1; index += 1) { + items.handle({ threadId: THREAD_ID, method: 'item/started', params: reasoning(`r-${index}`) }) + } + expect(rows.get(ROW)).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Thinking' }], + state: 'completed' + }) + }) + + it('ends with its streamed text when the completion itself carries none', () => { + const { translator, rows } = coalescedTurn() + translator.handle(notification('item/started', reasoning('r-1'), 2_000)) + translator.handle( + notification('item/reasoning/summaryTextDelta', { itemId: 'r-1', delta: '**Plan**' }) + ) + translator.handle(notification('item/completed', reasoning('r-1'), 7_000)) + expect(rows.get(ROW)).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: '**Plan**' }], + state: 'completed', + completedAt: 7_000 + }) + }) + + it('claims no span for a completion whose start was never seen', () => { + const { translator, rows } = coalescedTurn() + translator.handle(notification('item/completed', reasoning('r-1', ['Planned']), 7_000)) + expect(rows.get(ROW)).toMatchObject({ state: 'completed' }) + expect(rows.get(ROW)).not.toHaveProperty('completedAt') + }) + + it('leaves an evicted file change what a settle would, not its command output as a patch', () => { + const { rows, sink } = recorder() + const items = new CodexJournalItems( + { + sink, + attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + schedule: () => () => {} + }, + () => TURN_ID, + () => {} + ) + const changes = [{ path: 'src/app.ts', kind: { type: 'update' }, diff: '@@ -1 +1 @@' }] + items.handle({ + threadId: THREAD_ID, + method: 'item/started', + params: { item: { type: 'fileChange', id: 'patch-1', changes, status: 'inProgress' } } + }) + items.streams.handle(THREAD_ID, 'item/fileChange/outputDelta', { + itemId: 'patch-1', + delta: 'Success. Updated the following files:' + }) + for (let index = 2; index <= MAX_CODEX_ACTIVE_ITEMS + 1; index += 1) { + items.handle({ threadId: THREAD_ID, method: 'item/started', params: reasoning(`r-${index}`) }) + } + expect(rows.get('orca:codex-item%3Athread-abc%3Apatch-1')).toEqual({ + kind: 'status', + text: 'File changes were interrupted before completion.' + }) + }) + + it('keeps the start of an item whose started frame already carried text', () => { + const { translator, rows, firstObservedAt } = coalescedTurn() + translator.handle(notification('item/started', reasoning('r-1', ['Plan']), 2_000)) + expect(rows.get(ROW)).toMatchObject({ state: 'running' }) + translator.handle(notification('item/completed', reasoning('r-1', ['Plan']), 7_000)) + expect(firstObservedAt.get(ROW)).toBe(2_000) + expect(rows.get(ROW)).toMatchObject({ state: 'completed', completedAt: 7_000 }) + }) +}) diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 170c142ba43e..61856ef5f24e 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -38,6 +38,7 @@ import { } from './codex-structured-fast-mode' import { assertCodexConnectionOpen, + CODEX_RECEIPT_TIMED_METHODS, codexSessionLifecycle, mintCodexAcquisitionGeneration, type CodexAcquisitionRegistry, @@ -49,8 +50,6 @@ import type { CodexStructuredSessionTeardown } from './codex-structured-session- import type { CodexStructuredNotificationRetry } from './codex-structured-notification-retry' import type { deliverCodexServerRequest } from './codex-structured-provider-events' -const TURN_BOUNDARIES: ReadonlySet = new Set(['turn/started', 'turn/completed']) - export async function acquireCodexStructuredSession(input: { input: StructuredAgentSessionAcquireInput deps: CodexStructuredSessionAdapterDeps @@ -144,7 +143,9 @@ export async function acquireCodexStructuredSession(input: { { onNotification: (method, params) => { // Stamped at receipt, ahead of any pre-publication buffering or retry. - const observedAt = TURN_BOUNDARIES.has(method) ? (deps.now?.() ?? Date.now()) : undefined + const observedAt = CODEX_RECEIPT_TIMED_METHODS.has(method) + ? (deps.now?.() ?? Date.now()) + : undefined const dispatchSequenceAtReceipt = method === 'turn/started' ? dispatchEchoes.latestSequence() : undefined input.deliver( diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index 3066b5613364..97821f36c928 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -207,6 +207,29 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { expect(adapter.backgroundTaskStops('session-1')).toBeDefined() }) + it('times turn and item boundaries at receipt, and nothing else', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + await acquired(codex, {}, events) + const connection = codex.connections[0] + const item = { type: 'reasoning', id: 'r-1', summary: [], content: [] } + connection.handlers.onNotification?.('item/started', { threadId: THREAD_ID, item }) + connection.handlers.onNotification?.('item/reasoning/summaryTextDelta', { + threadId: THREAD_ID, + itemId: 'r-1', + delta: 'Planning' + }) + connection.handlers.onNotification?.('item/completed', { threadId: THREAD_ID, item }) + const timed = events.flatMap((event) => + event.type === 'notification' ? [[event.method, event.observedAt]] : [] + ) + expect(timed).toEqual([ + ['item/started', 1_700_000_000_500], + ['item/reasoning/summaryTextDelta', undefined], + ['item/completed', 1_700_000_000_500] + ]) + }) + it('ignores Codex traffic that arrives after the session is gone', async () => { const codex = fakeCodex() const adapter = await acquired(codex) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 5773b659f5e1..7669895937d5 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -45,6 +45,15 @@ export type CodexStructuredLaunch = { env?: Record } +/** Turn and item boundaries, timed by when the host received them, never by when a buffered or + * retried delivery got round to them. */ +export const CODEX_RECEIPT_TIMED_METHODS: ReadonlySet = new Set([ + 'turn/started', + 'turn/completed', + 'item/started', + 'item/completed' +]) + export type CodexStructuredSessionEvent = | { type: 'notification' @@ -52,7 +61,8 @@ export type CodexStructuredSessionEvent = threadId: string method: string params: unknown - /** Host receipt time of a turn boundary; survives retry and deferral so a replay is not re-stamped. */ + /** Host receipt time of a `CODEX_RECEIPT_TIMED_METHODS` boundary; survives retry and + * deferral so a replay is not re-stamped. */ observedAt?: number /** Highest dispatch sequence armed when this turn-start was first received. */ dispatchSequenceAtReceipt?: number diff --git a/src/main/codex/codex-unfinished-item-body.ts b/src/main/codex/codex-unfinished-item-body.ts new file mode 100644 index 000000000000..0234f1949259 --- /dev/null +++ b/src/main/codex/codex-unfinished-item-body.ts @@ -0,0 +1,72 @@ +import { + endedRunningAgentJournalToolCall, + type AgentJournalRunningCallEnd +} from '../../shared/agent-journal-tool-call-lifecycle' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' +import { + endedJournalReasoning, + withJournalReasoningLifecycle +} from '../native-chat/agent-session-journal/journal-reasoning-row' +import type { CodexStructuredItemStreams } from './codex-structured-item-stream-contracts' +import { + codexJournalItem, + codexStreamingJournalItem, + type CodexJournalItem +} from './codex-structured-item-translation' +import type { CodexActiveJournalItem } from './codex-structured-journal-contracts' + +/** What an item still open is built from, whoever ends it — its turn, its provider, the bounded + * live set, or a completion that carried nothing: the text streamed so far when there is any, + * else the item as it started, which usually carries none. */ +export function codexActiveItemBody( + active: CodexActiveJournalItem, + streams: Pick +): AgentJournalItemBody | null { + const streamed = streams.snapshot(active.threadId, active.item.id) + return ( + streamed + ? codexStreamingJournalItem(active.item, streamed.text) + : codexJournalItem(active.item, active.helperName) + ).body +} + +/** A reasoning completion that carried no text of its own still ends the row its stream wrote, + * from the same text a settle would use. */ +export function codexCompletedItem( + completed: CodexJournalItem, + active: CodexActiveJournalItem | undefined, + streams: Pick +): CodexJournalItem { + if (completed.body || !active || active.item.type !== 'reasoning') { + return completed + } + return { body: codexActiveItemBody(active, streams), handled: true } +} + +/** The row an item that will never complete is left with: a running call ended as its turn or + * session did (`end.call`; failed when nothing says), a patch said to be interrupted, a prompt + * cancelled, a message ended — at `end.at` when the host saw the end. */ +export function interruptedCodexItemBody( + body: AgentJournalItemBody | null, + end: { at?: number; call?: AgentJournalRunningCallEnd } = {} +): AgentJournalItemBody | null { + if (!body) { + return null + } + if (body.kind === 'tool-call') { + return end.call + ? endedRunningAgentJournalToolCall(body, end.call) + : { ...body, state: 'failed' } + } + if (body.kind === 'message') { + return withJournalReasoningLifecycle(body, endedJournalReasoning(end.at)) + } + if (body.kind === 'diff') { + return { kind: 'status', text: 'File changes were interrupted before completion.' } + } + return (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ? (cancelledJournalPromptBody(body) ?? body) + : body +} diff --git a/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts b/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts new file mode 100644 index 000000000000..e8aa2e736643 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reasoning-row.ts @@ -0,0 +1,39 @@ +import type { + AgentJournalItemBody, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' + +export type JournalReasoningLifecycle = Pick + +/** Null for blank reasoning: a block or item with no readable text journals no row. */ +export function journalReasoningBody( + text: string | null, + lifecycle: JournalReasoningLifecycle = {} +): AgentJournalMessageItem | null { + return text?.trim() + ? { + kind: 'message', + role: 'reasoning', + blocks: [ + { type: 'text', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text } + ], + ...lifecycle + } + : null +} + +/** Stamps a reasoning row with whether its block or item is still open, as the caller knows it; + * every other body passes through untouched. */ +export function withJournalReasoningLifecycle( + body: AgentJournalItemBody, + lifecycle: JournalReasoningLifecycle +): AgentJournalItemBody { + return body.kind === 'message' && body.role === 'reasoning' ? { ...body, ...lifecycle } : body +} + +/** A reasoning row's end: `completedAt` is when the host saw it end, or the turn or exit that cut + * it off; absent when no end was seen live. */ +export function endedJournalReasoning(completedAt?: number): JournalReasoningLifecycle { + return { state: 'completed', ...(completedAt === undefined ? {} : { completedAt }) } +} diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts index 073dcd12bc7d..0e66d72bc131 100644 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement.ts @@ -4,6 +4,7 @@ import { } from '../../../shared/agent-journal-tool-call-lifecycle' import type { AgentJournalItemBody, + AgentJournalMessageItem, AgentJournalTurnScope } from '../../../shared/agent-session-journal-types' import { @@ -11,6 +12,7 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { cancelledJournalPromptBody } from './journal-prompt-body-bounds' +import { endedJournalReasoning } from './journal-reasoning-row' /** True while an item is still awaiting the row that settles it, so a sink can * treat that row as lifecycle-critical rather than sheddable under pressure. */ @@ -24,6 +26,15 @@ export function requiresTerminalSettlement(body: AgentJournalItemBody): boolean return isRunningAgentJournalTurn(body) } +/** A message still open, ended by a sweep that cannot know when it stopped: no time is claimed. */ +export function endedUnseenMessageBody(body: AgentJournalItemBody): AgentJournalMessageItem | null { + if (body.kind !== 'message' || body.state !== 'running') { + return null + } + const { completedAt: _unseen, ...open } = body + return { ...open, ...endedJournalReasoning() } +} + /** The row that settles an item no one will finish: a running tool call ends as `end` (how its * turn or session ended) says, a pending prompt is cancelled. Null for an item that needs none. * A null `end` ends no call: another writer settled the turn, and its calls stay the provider's. diff --git a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts index 4f21285e4176..1bb537a86913 100644 --- a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts +++ b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts @@ -10,6 +10,8 @@ export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ 'message:stream_event:content_block_start', 'message:stream_event:content_block_delta', 'message:stream_event:content_block_stop', + // A Messages API keep-alive inside a stream; it carries nothing. + 'message:stream_event:ping', 'message:system:compact_boundary', 'message:system:status', 'message:system:api_retry', diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 06c5ba73bdca..141e9ff24bca 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -38,6 +38,9 @@ describe('provider frame classification catalog', () => { expect(classifyProviderFrame('claude', 'message:system:hook_started', {})).toBe( 'suppressed-benign' ) + expect(classifyProviderFrame('claude', 'message:stream_event:ping', {})).toBe( + 'suppressed-benign' + ) }) it('promotes payload failures over a benign catalog classification', () => { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index 56c5244e2ff2..da399958eb8e 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -102,6 +102,7 @@ export const PROVIDER_FRAME_CLASSIFICATIONS = { 'message:stream_event:content_block_start': 'status-chrome', 'message:stream_event:content_block_delta': 'stream-into-item', 'message:stream_event:content_block_stop': 'status-chrome', + 'message:stream_event:ping': 'suppressed-benign', 'message:system:compact_boundary': 'status-chrome', 'message:system:status': 'status-chrome', 'message:system:api_retry': 'status-chrome', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index 25703ea5ba55..3595ff6b0f8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -14,6 +14,7 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { partitionJournalLifecycleMutations } from '../agent-session-journal/journal-lifecycle-batch-partition' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' import { + endedUnseenMessageBody, requiresTerminalSettlement, runningCallEnd, terminalAgentJournalBody @@ -181,9 +182,10 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const bodies = new Map(items.map((item) => [item.itemId, item.body])) for (const item of items) { const identity = parseAgentJournalItemKey(item.itemId) - // Ended as its turn is: a proven death cuts a running call short. + // Ended as its turn is: a proven death cuts a running call short. An open reasoning row is + // ended too, but is not unfinished work: its running turn already says so. const end = runningCallEnd(item.turnScope, (id) => bodies.get(id), input.verdict.state) - const body = terminalAgentJournalBody(item.body, end) + const body = endedUnseenMessageBody(item.body) ?? terminalAgentJournalBody(item.body, end) if (identity && body) { mutations.push({ kind: 'item', @@ -246,7 +248,7 @@ export async function settleStaleStructuredAgentSessionState(input: { // A turn already settled (a person's Stop) ends its calls as it ended; only a turn still running // leaves them to the evidence. const end = runningCallEnd(item.turnScope, (id) => journal.itemBody(id), verdictFor(item).state) - const body = terminalAgentJournalBody(item.body, end) + const body = endedUnseenMessageBody(item.body) ?? terminalAgentJournalBody(item.body, end) if (identity && body) { mutations.push({ kind: 'item', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts new file mode 100644 index 000000000000..c54a65a5862c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts @@ -0,0 +1,110 @@ +// A reasoning row a dead generation left open is ended by both host sweeps, and is not by itself +// evidence that a response was interrupted. +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 { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + captureUnfinishedStructuredAgentSessionWork, + settleStaleStructuredAgentSessionState, + settleStructuredAgentSessionDeadGeneration +} from './structured-agent-session-dead-generation-settlement' + +const SESSION = 'session-reasoning-sweep' +const THREAD = 'thread-1' +const REASONING = { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } as const +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-reasoning-sweep-')) + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle(THREAD) + }, + database: openTestJournalHostDatabase(root), + now: () => 1_000 + }) +}) + +afterEach(async () => { + await journal.close() + await rm(root, { recursive: true, force: true }) +}) + +async function seedOpenReasoning(turnState: 'running' | 'completed'): Promise { + await journal.appendItem( + REASONING, + { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing options' }], + state: 'running' + }, + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 }, + { kind: 'turn', turnId: 'turn-1', state: turnState, startedAt: 900 }, + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function reasoningBody(): AgentJournalItemBody | undefined { + return journal + .snapshot() + .items.find((item) => item.body.kind === 'message' && item.body.role === 'reasoning')?.body +} + +describe('an open reasoning row a dead generation left', () => { + it('ends, with no claimed time, when the generation is settled at its exit', async () => { + await seedOpenReasoning('running') + await expect( + settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 8, + settlementId: `expected-close:${SESSION}:8`, + pendingSubmissionReason: 'provider_closed_before_acknowledgement', + verdict: { state: 'interrupted', completedAt: 1_500 }, + showUnexpectedExitOutcome: false + }) + ).resolves.toEqual({ ok: true }) + expect(reasoningBody()).toEqual({ + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing options' }], + state: 'completed' + }) + }) + + it('ends when a later acquisition or reopen sweeps the stale generation', async () => { + await seedOpenReasoning('running') + await settleStaleStructuredAgentSessionState({ + journal, + sessionId: SESSION, + fence: 8, + acquisitionGeneration: 'generation-8', + deathEvidence: null + }) + expect(reasoningBody()).toMatchObject({ state: 'completed' }) + expect(reasoningBody()).not.toHaveProperty('completedAt') + }) + + it('is not by itself unfinished work, so it adds no exit row to a settled turn', async () => { + await seedOpenReasoning('completed') + expect(captureUnfinishedStructuredAgentSessionWork(journal).items).toEqual([]) + }) +}) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index bbafd5f1aae4..b297404a6f48 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -251,6 +251,10 @@ beforeEach(async () => { resolveShellEnvironmentPolicy: () => shellEnvironmentPolicy, resolveClaudeAuthPolicy: () => claudeAuthPolicy, openClaudeConnection: claude.openConnection, + claudeThinkingDisplay: { + argsFor: async () => ({ 'thinking-display': 'summarized' }), + observeExit: () => {} + }, // Production's sink wiring onto a real hook server, whose records a Stop reaches. statusSink: { publish: (summary, subject) => hookServer.ingestStructuredStatus(summary, subject), @@ -333,6 +337,15 @@ describe('a structured Claude session over agentSession.*', () => { }) }) + // The runtime builds each agent's adapter from a registration; this one must reach Claude's. + it('asks the Claude CLI for readable thinking when the runtime knows it takes the flag', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + + expect(claude.live().launch.options.extraArgs).toMatchObject({ + 'thinking-display': 'summarized' + }) + }) + it('passes shell exports straight to the child, as a terminal would', async () => { shellEnv = { ...shellEnv, diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4333cc7e3a5e..f299e17b82ee 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -26,6 +26,7 @@ import { nativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell import { claudeStructuredPermissionModeForSettings } from '../claude/claude-structured-permission-mode' import { codexStructuredPermissionPolicyForSettings } from '../codex/codex-structured-permission-policy' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' +import { claudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVisibleReadProbe { async getWorktreePs( @@ -154,6 +155,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), resolveClaudeLaunchEnv: () => resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), + // Wired only here, so a test runtime never runs a real `claude --version`. + claudeThinkingDisplay: claudeThinkingDisplaySupport, resolveShellEnvironmentPolicy: () => nativeChatShellEnvironmentPolicy(this.requireStore().getSettings()), resolveClaudeAuthPolicy: () => diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 5db941bb8d88..bfe4e2188930 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -115,6 +115,7 @@ function createClaudeAdapter( store, resolveWorkspacePath: deps.resolveWorkspacePath, ...(deps.resolveClaudeCommand ? { resolveClaudeCommand: deps.resolveClaudeCommand } : {}), + ...(deps.claudeThinkingDisplay ? { claudeThinkingDisplay: deps.claudeThinkingDisplay } : {}), ...(deps.resolveClaudeLaunchEnv ? { resolveClaudeLaunchEnv: deps.resolveClaudeLaunchEnv } : {}), resolveClaudeInheritedEnv: context.environment.resolveClaudeInheritedEnv, resolveClaudeAuthPolicy: deps.resolveClaudeAuthPolicy, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 88544a6c8067..c347be272708 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -59,6 +59,7 @@ import { modelCatalogHostDeps, type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' +import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' /** Whether this profile holds a structured chat: a record or tab in the journal database, or the * records file a profile from before it carries while the database still owes its copy. */ @@ -93,6 +94,8 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string + /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ + claudeThinkingDisplay?: ClaudeThinkingDisplaySupport /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 8b4b70f8bc2b..cb25f94db7c8 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -18,11 +18,15 @@ import { import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { ClaudeAtRestCommandCatalog } from '../claude/claude-at-rest-commands' +import { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' +import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' export type StructuredClaudeRuntimeAdapterDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveClaudeCommand?: () => string + /** Whether a Claude CLI takes the thinking-display flag; absent never passes it. */ + claudeThinkingDisplay?: ClaudeThinkingDisplaySupport resolveClaudeLaunchEnv?: () => Promise> | Record /** The env a Claude child inherits before auth stripping; absent inherits Orca's own. */ resolveClaudeInheritedEnv?: () => Promise> @@ -94,7 +98,8 @@ export function createStructuredClaudeRuntimeAdapter( : {}), ...(deps.readClaudeManagedAccountGate ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } - : {}) + : {}), + ...(deps.claudeThinkingDisplay ? { thinkingDisplay: deps.claudeThinkingDisplay } : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence @@ -134,8 +139,38 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.logger ? { logger: deps.logger } : {}), - ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), + ...openClaudeConnectionOf(deps), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), ...(deps.modelCatalog ? { modelCatalog: deps.modelCatalog } : {}) }) } + +/** The child's connection, watched for a CLI refusing the thinking-display flag, so the start that + * failed on it is the last one to pass it. */ +export function openClaudeConnectionOf( + deps: Pick +): Pick { + const support = deps.claudeThinkingDisplay + if (!support) { + return deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {} + } + const open = deps.openClaudeConnection ?? openClaudeStreamJsonConnection + return { + openConnection: (launch, handlers = {}, ...rest) => + open( + launch, + { + ...handlers, + // Every argument passes through, so one the connection adds later still reaches the session. + onExit: (...args) => { + support.observeExit( + { command: launch.pathToClaudeCodeExecutable, cwd: launch.cwd }, + args[0] + ) + handlers.onExit?.(...args) + } + }, + ...rest + ) + } +} diff --git a/src/main/runtime/structured-claude-thinking-display-refusal.test.ts b/src/main/runtime/structured-claude-thinking-display-refusal.test.ts new file mode 100644 index 000000000000..817add96e676 --- /dev/null +++ b/src/main/runtime/structured-claude-thinking-display-refusal.test.ts @@ -0,0 +1,97 @@ +// A real child, through the real SDK connection, refusing the thinking-display flag the way an +// older Claude CLI does: commander's message and exit code 1. +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLAUDE_STRUCTURED_BASE_OPTIONS } from '../claude/claude-structured-launch-resolution' +import { fakeClaude } from '../claude/claude-structured-session-test-support' +import { createClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' +import { openClaudeConnectionOf } from './structured-claude-runtime-adapter' + +const FAKE_CLI = join( + __dirname, + '..', + 'claude', + '__fixtures__', + 'claude-agent-sdk-scripted-cli.mjs' +) +const scratchDirs: string[] = [] + +afterEach(() => { + for (const dir of scratchDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +async function startRefusing(stderr: string) { + const dir = mkdtempSync(join(tmpdir(), 'claude-thinking-display-')) + scratchDirs.push(dir) + const scenarioPath = join(dir, 'scenario.json') + writeFileSync(scenarioPath, JSON.stringify({ steps: [{ stderr }, { exit: 1 }] })) + const env = { PATH: process.env.PATH ?? '', ORCA_SDK_CONTRACT_SCENARIO_PATH: scenarioPath } + const probe = vi.fn(async () => '2.1.280') + const support = createClaudeThinkingDisplaySupport({ + probe, + keyOf: async (command, cwd) => `${command}\n${cwd}`, + budgetMs: 1_000, + now: () => performance.now() + }) + const flag = await support.argsFor({ command: FAKE_CLI, cwd: dir, env }) + const { openConnection } = openClaudeConnectionOf({ claudeThinkingDisplay: support }) + let exited: Error | null = null + const connection = await openConnection!( + { + pathToClaudeCodeExecutable: FAKE_CLI, + options: { + ...CLAUDE_STRUCTURED_BASE_OPTIONS, + extraArgs: { ...CLAUDE_STRUCTURED_BASE_OPTIONS.extraArgs, ...flag } + }, + cwd: dir, + env + }, + { onExit: (error) => (exited = error) } + ) + await vi.waitFor(() => expect(exited).not.toBeNull(), { timeout: 10_000 }) + await connection.close() + return { support, probe, flag, launch: { command: FAKE_CLI, cwd: dir, env } } +} + +describe('a Claude CLI that refuses the thinking-display flag', () => { + it('fails that one start as today, and the next launch skips the flag', async () => { + const { support, probe, flag, launch } = await startRefusing( + "error: unknown option '--thinking-display'\n" + ) + expect(flag).toEqual({ 'thinking-display': 'summarized' }) + await vi.waitFor(async () => expect(await support.argsFor(launch)).toEqual({})) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('hands the session whether its close was the one Orca began', async () => { + const claude = fakeClaude() + const support = createClaudeThinkingDisplaySupport({ + probe: async () => '2.1.280', + keyOf: async () => null, + budgetMs: 1_000, + now: () => performance.now() + }) + const { openConnection } = openClaudeConnectionOf({ + claudeThinkingDisplay: support, + openClaudeConnection: claude.openConnection + }) + const onExit = vi.fn() + await openConnection!( + { pathToClaudeCodeExecutable: FAKE_CLI, options: {}, cwd: '/w' }, + { onExit } + ) + const exited = new Error('closed') + claude.connections[0]!.handlers.onExit?.(exited, { expected: true }) + // Without it, a Stop's own exit would read as the child exiting on its own. + expect(onExit).toHaveBeenCalledWith(exited, { expected: true }) + }) + + it('records nothing when the start failed for another reason', async () => { + const { support, launch } = await startRefusing('claude: not signed in\n') + await expect(support.argsFor(launch)).resolves.toEqual({ 'thinking-display': 'summarized' }) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx new file mode 100644 index 000000000000..505c87d763e0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.live-reasoning.test.tsx @@ -0,0 +1,308 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { NativeChatLiveSession } from './use-native-chat-live-session' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const STARTED = 1_000 + +const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: STARTED - 500, + source: 'transcript' +} + +function reasoning( + id: string, + text: string, + state: 'running' | 'completed', + fields: Partial = {} +): NativeChatMessage { + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp: STARTED, + source: 'transcript', + state, + ...(state === 'completed' ? { completedAt: STARTED + 12_000 } : {}), + ...fields + } +} + +/** The journal that says the turn runs and what its newest content is. */ +function journal(rows: readonly NativeChatMessage[]): AgentJournalRenderItem[] { + return [ + { + itemId: prompt.id, + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: prompt.blocks } + }, + { + itemId: 'turn-1', + revision: 1, + sequence: 2, + observedAt: 2, + body: { kind: 'turn', turnId: 'turn-1', state: 'running', userItemId: prompt.id } + }, + ...rows.map((row, index) => ({ + itemId: row.id, + revision: 1, + sequence: index + 3, + observedAt: index + 3, + ...(row.agentId ? { agentId: row.agentId } : {}), + body: { + kind: 'message' as const, + role: row.role, + blocks: row.blocks, + ...(row.state ? { state: row.state } : {}) + } + })) + ] +} + +function sessionOf(messages: NativeChatMessage[]): NativeChatLiveSession { + return { + messages, + status: 'working', + sessionId: 'session-1', + agent: 'claude', + hasMore: false, + loadingEarlier: false, + olderHistoryGeneration: 0, + loadEarlier: vi.fn(), + readPhase: 'ready' + } +} + +function list( + rows: readonly NativeChatMessage[], + props: Partial> = {} +): React.JSX.Element { + return ( + + ) +} + +const liveLine = (): HTMLElement => + screen.getByText('Thinking').closest('[data-native-chat-turn-activity]')! + +describe('live reasoning, read through the one live line', () => { + it('shows one "Thinking", collapsed, and no row for the open block', () => { + render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + expect(screen.getAllByText('Thinking')).toHaveLength(1) + const toggle = screen.getByRole('button', { name: 'Thinking' }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(liveLine()).toContainElement(toggle) + expect(screen.queryByRole('button', { name: /Reasoning|Thought/ })).toBeNull() + expect(screen.queryByText('Weighing two approaches')).toBeNull() + }) + + it('opens to the live text, which follows the block as it grows', () => { + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + rerender(list([reasoning('r-1', 'Weighing two approaches, then the cheaper one', 'running')])) + expect(screen.getByText('Weighing two approaches, then the cheaper one')).toBeInTheDocument() + }) + + it('keeps the body out of the live region, which announces the label only', () => { + render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + const body = screen.getByText('Weighing two approaches') + expect(body.closest('[aria-live]')).toBeNull() + expect(screen.getByText('Thinking').closest('[aria-live]')).not.toBeNull() + }) + + it('lands the finished row open when the reader opened it live, while the turn works on', () => { + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) + // The line no longer discloses anything; the row does, still open. + expect(screen.queryByRole('button', { name: /Thinking|Working/ })).toBeNull() + expect(screen.getByRole('button', { name: 'Reasoning: Thought for 12s' })).toHaveAttribute( + 'aria-expanded', + 'true' + ) + expect(screen.getByText('Weighing two approaches')).toBeInTheDocument() + }) + + // The body owns its colour: inherited, it read full foreground under the line and muted in the row, + // so the text dimmed as the block landed. + it('draws the same body, in its own quieter tone, live and once landed', () => { + const body = () => + screen.getByText('Weighing two approaches').closest('[data-native-chat-message-tone]') + const { rerender } = render(list([reasoning('r-1', 'Weighing two approaches', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + const live = body() + expect(live).toHaveAttribute('data-native-chat-message-tone', 'faint') + expect(live).toHaveClass('text-chat-foreground-faint', 'pl-5.5', 'max-h-80') + const liveClasses = live?.getAttribute('class') + rerender(list([reasoning('r-1', 'Weighing two approaches', 'completed')])) + expect(body()?.getAttribute('class')).toBe(liveClasses) + }) + + it('starts the next block collapsed', () => { + const { rerender } = render(list([reasoning('r-1', 'First thought', 'running')])) + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + rerender( + list([reasoning('r-1', 'First thought', 'completed'), reasoning('r-2', 'Second', 'running')]) + ) + expect(screen.getByRole('button', { name: 'Thinking' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.queryByText('Second')).toBeNull() + }) + + it('is not expandable while the open block has no text yet', () => { + render(list([reasoning('r-1', '', 'running')])) + expect(screen.getAllByText('Thinking')).toHaveLength(1) + expect(screen.queryByRole('button', { name: 'Thinking' })).toBeNull() + }) + + it('draws the open row when a waiting prompt replaces the line', () => { + render( + list([reasoning('r-1', 'Weighing two approaches', 'running')], { + awaitingInput: 'unshown' + }) + ) + expect(screen.queryByText('Thinking')).toBeNull() + expect(screen.getByRole('button', { name: 'Reasoning' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + }) + + // The open block can sit on a slot kept for its turn's bar or diff rollup; only the slot stays. + it('draws no row for the open block under a diff rollup on its turn', () => { + const edit: NativeChatMessage = { + id: 'edit-1', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Diff', input: { path: 'a.ts' }, state: 'completed' }, + { type: 'tool-result', output: '@@ -1 +1 @@\n-old\n+new' } + ], + timestamp: STARTED, + source: 'transcript' + } + render( + list([ + edit, + reasoning('r-1', 'Weighing two approaches', 'running', { timestamp: STARTED + 50 }) + ]) + ) + expect(screen.queryByRole('button', { name: /Reasoning/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getAllByText('Weighing two approaches')).toHaveLength(1) + }) + + it('draws no row for the open block that carries a provider-opened turn bar', () => { + const asked: NativeChatMessage = { ...prompt, id: 'u1', timestamp: 1 } + const done: NativeChatMessage = { + id: 'a1', + role: 'assistant', + blocks: [{ type: 'text', text: 'Done.' }], + timestamp: 2, + source: 'transcript' + } + const woke = reasoning('r-w', 'Checking the background build', 'running', { timestamp: 3 }) + const thread = { kind: 'thread' as const } + const inTurn = (turnItemId: string) => ({ kind: 'turn' as const, turnItemId }) + const item = ( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'], + turnScope: AgentJournalRenderItem['turnScope'] + ): AgentJournalRenderItem => ({ + itemId, + revision: 0, + sequence, + observedAt: sequence, + turnScope, + body + }) + const items = [ + item('u1', 1, { kind: 'message', role: 'user', blocks: asked.blocks }, thread), + item('t1', 2, { kind: 'turn', turnId: 't1', state: 'completed', userItemId: 'u1' }, thread), + item('a1', 3, { kind: 'message', role: 'assistant', blocks: done.blocks }, inTurn('t1')), + item( + 'wake', + 4, + { kind: 'turn', turnId: 'wake', state: 'running', userItemId: 'claude:wake' }, + thread + ), + item( + 'r-w', + 5, + { kind: 'message', role: 'reasoning', blocks: woke.blocks, state: 'running' }, + inTurn('wake') + ) + ] + render( + list([], { + session: sessionOf([asked, done, woke]), + journalItems: items + }) + ) + expect(screen.getByText(/Working for/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Reasoning/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Thinking' })) + expect(screen.getAllByText('Checking the background build')).toHaveLength(1) + }) + + // A live region only announces changes to itself; a replaced one says nothing. + it('keeps one live region while the line turns into the disclosure and back', () => { + const tool: NativeChatMessage = { + id: 'tool-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'shell', input: { command: 'ls' }, state: 'completed' }], + timestamp: STARTED, + source: 'transcript' + } + const later = { timestamp: STARTED + 50 } + const region = () => document.querySelector('[data-native-chat-turn-activity][aria-live]') + const { rerender } = render(list([tool])) + const before = region() + expect(before).toHaveTextContent('Working…') + rerender(list([tool, reasoning('r-1', 'Weighing two approaches', 'running', later)])) + expect(region()).toBe(before) + expect(before).toHaveTextContent('Thinking') + rerender( + list([ + tool, + reasoning('r-1', 'Weighing two approaches', 'completed', later), + { ...tool, id: 'tool-2', timestamp: STARTED + 60 } + ]) + ) + expect(region()).toBe(before) + expect(before).toHaveTextContent('Working…') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 5e406876a564..471f6fd3470c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -29,10 +29,10 @@ import { import type { NativeChatTranscriptRowContext } from './NativeChatTranscriptRow' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import { - buildNativeChatTranscriptSlots, splitNativeChatSlotsWaitingBehindLiveTurn, nativeChatSlotIndexOf } from './native-chat-transcript-slots' +import { useNativeChatTranscriptSlots } from './use-native-chat-transcript-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' import { useNativeChatSubagentSections } from './use-native-chat-subagent-sections' @@ -187,37 +187,25 @@ export function NativeChatMessageList({ : null const lifecycleWorking = session.transcriptLifecycle?.state === 'working' const { measureContent, typography } = useNativeChatRowTypography(contentRef) - const allSlots = useMemo( - () => - buildNativeChatTranscriptSlots({ - typography, - messages: rows, - turnKeys, - liveTurnKey, - receipts, - turnStatuses, - turnDiffs, - expandedTurnKeys: expandedTurnIds, - isWorking, - lifecycleWorking, - subagentSections, - subagentChoices - }), - [ - typography, - liveTurnKey, - expandedTurnIds, - isWorking, - lifecycleWorking, - receipts, - rows, - subagentChoices, - subagentSections, - turnDiffs, - turnKeys, - turnStatuses - ] - ) + const { slots: allSlots, liveLine } = useNativeChatTranscriptSlots({ + typography, + messages: rows, + turnKeys, + liveTurnKey, + receipts, + turnStatuses, + turnDiffs, + expandedTurnKeys: expandedTurnIds, + isWorking, + lifecycleWorking, + subagentSections, + subagentChoices, + line: { + draws: tailRow === 'activity', + thinking: turnStatuses.active?.thinking === true, + activityText: turnActivity?.text + } + }) // A message waiting behind the live turn draws after that turn's live activity, not inside it. const { slots, waitingSlots } = useMemo( () => splitNativeChatSlotsWaitingBehindLiveTurn(allSlots, journalItems), @@ -396,10 +384,11 @@ export function NativeChatMessageList({ context={rowContext} window={transcriptWindow} /> - {tailRow === 'activity' ? ( + {liveLine ? ( ) : tailRow === 'awaiting-input' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 01c5b890b9c0..a7e8ef689bcd 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -115,11 +115,16 @@ describe('MessageRow control visibility', () => { expect(screen.queryAllByRole('button')).toHaveLength(role === 'assistant' ? 2 : 1) }) - it.each(['reasoning', 'system'] as const)('preserves chrome-free %s rows', (role) => { - renderMessage(role) - expect(screen.queryByRole('time')).toBeNull() - expect(screen.queryByRole('button')).toBeNull() - }) + it.each(['reasoning', 'system'] as const)( + 'omits timestamp and agent controls on %s rows', + (role) => { + renderMessage(role) + expect(screen.queryByRole('time')).toBeNull() + expect(screen.queryByRole('button', { name: 'Copy message' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Scroll this message to top' })).toBeNull() + expect(screen.queryAllByRole('button')).toHaveLength(role === 'reasoning' ? 1 : 0) + } + ) }) describe('MessageRow send mode', () => { diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index b6c241231176..a19d41ad2a58 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -13,6 +13,7 @@ import type { } from '../../../../shared/native-chat-types' import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' import { NativeChatToolRun } from './NativeChatToolRun' +import { NativeChatReasoningRow } from './NativeChatReasoningRow' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { NativeChatCopyButton } from './NativeChatCopyButton' @@ -92,7 +93,6 @@ export const MessageRow = memo(function MessageRow({ onLinkClick, allowFileUriLinks = false, deliveryNotice, - folded = false, subagentRoster, subagentDisclosure, inSubagentSection = false, @@ -112,8 +112,6 @@ export const MessageRow = memo(function MessageRow({ onLinkClick?: CommentMarkdownLinkClickHandler allowFileUriLinks?: boolean deliveryNotice?: NativeChatDeliveryNotice - /** Behind a folded turn: the row keeps only what outlives the turn. */ - folded?: boolean /** On a roster row: its list's state and the subagents whose rows open below it. */ subagentRoster?: NativeChatSubagentRosterState subagentDisclosure?: NativeChatSubagentDisclosure @@ -152,12 +150,6 @@ export const MessageRow = memo(function MessageRow({ return null } - // Behind a folded turn this row is the work, not the answer. Rows that outlive - // their turn never reach here — the fold leaves them out. - if (folded) { - return null - } - const notice = isSystem ? message.blocks.find( (block) => @@ -248,9 +240,22 @@ export const MessageRow = memo(function MessageRow({ ) } - // Plain assistant prose is the copyable unit; reasoning/system asides stay - // chrome-free. Controls reveal on hover/keyboard focus and stay visible on touch. - const showControls = !isReasoning && !isSystem && markdown.length > 0 + if (isReasoning) { + return ( +
+ +
+ ) + } + + // Assistant controls reveal on hover and keyboard focus; system asides stay chrome-free. + const showControls = !isSystem && markdown.length > 0 return (
+ +
+ ) +} + +/** The disclosure caret of a `group/reasoning` header: shown on hover, keyboard focus (on the + * header or a trigger inside it) and touch, and turned while open. */ +export function NativeChatReasoningChevron(): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx new file mode 100644 index 000000000000..05ce6c8807a0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatReasoningRow.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatReasoningRow } from './NativeChatReasoningRow' +import { MessageRow } from './NativeChatMessageRow' +import { NativeChatToolRunIcon } from './NativeChatToolIcon' +import { + NativeChatDisclosureContext, + useNativeChatDisclosures +} from './native-chat-disclosure-store' + +vi.mock('@/components/sidebar/CommentMarkdown', () => ({ + default: ({ content }: { content: string }) =>
{content}
+})) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +const STARTED = 100_000 + +describe('reasoning disclosure', () => { + it('starts collapsed without mounting markdown', () => { + render( + + ) + expect(screen.getByRole('button', { name: 'Reasoning: Thought' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() + // The same category glyph slot a tool row leads with, hidden from the accessible name. + const glyph = screen.getByRole('button').querySelector('svg.lucide-brain') + expect(glyph).toHaveAttribute('aria-hidden', 'true') + }) + + it('leads with the shared vocabulary brain, drawn exactly as a tool row draws a glyph', () => { + const { container } = render( + + ) + const shared = container.innerHTML + cleanup() + render( + + ) + expect( + screen.getByRole('button').querySelector('svg.lucide-brain')?.parentElement?.outerHTML + ).toBe(shared) + }) + + it('hides its chevron only where hover can reveal it, and shows it on keyboard focus and once open', () => { + render( + + ) + // An SVG's `className` is an `SVGAnimatedString`, so read the attribute. + const chevron = screen.getByRole('button').querySelector('svg.lucide-chevron-right') + const classes = (chevron?.getAttribute('class') ?? '').split(' ') + // Touch has no hover, so an ungated `opacity-0` would hide it there for good. + expect(classes).not.toContain('opacity-0') + expect(classes).toEqual( + expect.arrayContaining([ + 'can-hover:opacity-0', + 'group-hover/reasoning:opacity-100', + 'group-focus-visible/reasoning:opacity-100', + 'group-data-[state=open]/reasoning:opacity-100' + ]) + ) + }) + + it('expands through a native button and keeps disclosure state through revisions', () => { + const message = { + id: 'r-1', + role: 'reasoning' as const, + timestamp: STARTED, + state: 'completed' as const + } + const { rerender } = render() + const trigger = screen.getByRole('button') + expect(trigger.tagName).toBe('BUTTON') + fireEvent.click(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('markdown')).toHaveTextContent('Inspecting') + rerender() + expect(screen.getByRole('button')).toBe(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('markdown')).toHaveTextContent('More') + // Collapsed again by the user, it stays collapsed through the next revision. + fireEvent.click(trigger) + rerender() + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByTestId('markdown')).not.toBeInTheDocument() + }) + + it('keeps its disclosure under the block key, so a remount (or the live line) finds it open', () => { + const Transcript = ({ children }: { children: React.ReactNode }) => { + const disclosures = useNativeChatDisclosures() + return ( + + {children} + + ) + } + const row = ( + + ) + const { rerender } = render({row}) + fireEvent.click(screen.getByRole('button')) + // Windowed out, then back. + rerender({null}) + rerender({row}) + expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByTestId('markdown')).toHaveTextContent('Inspecting') + }) + + it.each(['', ' \n\t'])('draws nothing for blank reasoning %j', (markdown) => { + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) +}) + +describe('the reasoning headline', () => { + const headline = ( + message: Pick, + turnIsWorking = false + ) => { + render( + + ) + return screen.queryByRole('button')?.textContent ?? null + } + + // The live line hides the one block it discloses; any other open row draws, claiming no end. + it('reads Reasoning while the row is open and its turn or subagent is running', () => { + expect(headline({ timestamp: STARTED, state: 'running' }, true)).toBe('Reasoning') + }) + + it('reads Thought for N s once it closes, while the turn goes on working', () => { + expect( + headline({ timestamp: STARTED, state: 'completed', completedAt: STARTED + 12_000 }, true) + ).toContain('Thought for 12s') + }) + + it('measures the span the host saw, at least one second', () => { + expect( + headline({ timestamp: STARTED, state: 'completed', completedAt: STARTED + 65_000 }) + ).toContain('Thought for 1m 5s') + cleanup() + expect( + headline({ timestamp: STARTED, state: 'completed', completedAt: STARTED + 300 }) + ).toContain('Thought for 1s') + }) + + it('claims no duration it never saw, and draws an open row in a settled turn as Thought', () => { + expect(headline({ timestamp: STARTED, state: 'completed' })).toBe('Reasoning: Thought') + cleanup() + expect(headline({ timestamp: STARTED, state: 'running' })).toBe('Reasoning: Thought') + }) + + it('stays neutral for a row from a host that kept no lifecycle', () => { + expect(headline({ timestamp: STARTED }, true)).toBe('Reasoning') + }) + + it('draws through the message row while open, and reads its span once it closes', () => { + const message: NativeChatMessage = { + id: 'reasoning-1', + role: 'reasoning', + source: 'transcript', + timestamp: STARTED, + state: 'running', + blocks: [{ type: 'text', text: 'Inspecting the request\nFull reasoning' }] + } + const { rerender } = render( + + ) + expect(screen.getByRole('button')).toHaveTextContent('Reasoning') + rerender( + + ) + expect(screen.getByRole('button')).toHaveTextContent('Thought for 3s') + fireEvent.click(screen.getByRole('button')) + expect(screen.getByTestId('markdown')).toHaveTextContent('Full reasoning') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx b/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx new file mode 100644 index 000000000000..040bd6c6206d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatReasoningRow.tsx @@ -0,0 +1,77 @@ +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { translate } from '@/i18n/i18n' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + nativeChatReasoningDisclosureKey, + nativeChatReasoningHeadline, + type NativeChatReasoningHeadline +} from '../../../../shared/native-chat-reasoning-row' +import { + NativeChatReasoningBody, + NativeChatReasoningChevron +} from './NativeChatReasoningDisclosure' +import { NativeChatToolRunIcon } from './NativeChatToolIcon' +import { useNativeChatDisclosure } from './native-chat-disclosure-store' + +function translatedHeadline(headline: NativeChatReasoningHeadline): string { + if (headline.kind === 'thoughtFor') { + return translate('components.native-chat.thoughtForDuration', 'Thought for {{duration}}', { + duration: headline.duration + }) + } + return headline.kind === 'thought' + ? translate('components.native-chat.thought', 'Thought') + : translate('components.native-chat.reasoning', 'Reasoning') +} + +export function NativeChatReasoningRow({ + message, + markdown, + turnIsWorking = false, + onLinkClick, + allowFileUriLinks +}: { + message: Pick + markdown: string + /** The row's own turn (or subagent) is still running; an open row there has not ended. */ + turnIsWorking?: boolean + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean +}): React.JSX.Element | null { + // Keyed like the live line, so a block opened while it streamed lands open, and windowing keeps it. + const disclosure = useNativeChatDisclosure(nativeChatReasoningDisclosureKey(message.id), false) + if (!markdown.trim()) { + return null + } + const label = translate('components.native-chat.reasoning', 'Reasoning') + const headline = translatedHeadline(nativeChatReasoningHeadline(message, { live: turnIsWorking })) + + return ( +
+ + + {/* Laid out like a tool run's header, so its glyph sits in the same column. */} + + + + + + +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx index 11bde34c6ced..01f92299000b 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolIcon.tsx @@ -1,5 +1,6 @@ import { Bot, + Brain, Eye, Folder, Globe, @@ -29,7 +30,8 @@ const NATIVE_CHAT_TOOL_GLYPHS: Record = { bot: Bot, 'list-checks': ListChecks, wrench: Wrench, - 'message-square-more': MessageSquareMore + 'message-square-more': MessageSquareMore, + brain: Brain } /** The fixed 16px slot with a 14px glyph, which keeps every row left-aligned diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index bcb3841cbb9c..f1464b11b7d0 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -95,7 +95,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
{/* A turn with no user bubble carries its bar above its first row. */} {slot.statusAbove ? statusRow : null} - {receipt ? ( + {!slot.drawsMessage ? null : receipt ? ( ) : ( 0} diff --git a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx index 4d59d159f25e..4a3d62a2f8fe 100644 --- a/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx @@ -1,35 +1,83 @@ +import { useId } from 'react' import { Loader2 } from 'lucide-react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { translate } from '@/i18n/i18n' -import type { NativeChatTurnActivity } from '../../../../shared/native-chat-turn-activity' +import type { NativeChatLiveLine } from '../../../../shared/native-chat-live-line' +import { nativeChatReasoningDisclosureKey } from '../../../../shared/native-chat-reasoning-row' import { describeNativeChatActiveTurnLabel } from '../../../../shared/native-chat-turn-status' +import { + NativeChatReasoningBody, + NativeChatReasoningChevron +} from './NativeChatReasoningDisclosure' +import { useNativeChatDisclosure } from './native-chat-disclosure-store' /** The live turn's tail line: a spinner plus what the turn is doing right now — * the provider's activity text, else that it is reasoning, else plain "Working…". - * The clock lives in the turn bar under the user's message, not here. */ + * The clock lives in the turn bar under the user's message, not here. While the + * agent's open reasoning block has text, the line is that block's disclosure. */ export function NativeChatTurnActivityLine({ - activity, - thinking + line, + onLinkClick, + allowFileUriLinks }: { - activity?: NativeChatTurnActivity | null - thinking: boolean + line: NativeChatLiveLine + onLinkClick?: CommentMarkdownLinkClickHandler + allowFileUriLinks?: boolean }): React.JSX.Element { - const resolved = describeNativeChatActiveTurnLabel({ activityText: activity?.text, thinking }) + const resolved = describeNativeChatActiveTurnLabel(line) const label = resolved.source === 'activity' ? resolved.text : resolved.key === 'thinking' ? translate('components.native-chat.status.thinking', 'Thinking') : translate('components.native-chat.status.working', 'Working…') + const { reasoning } = line + // The finished row reads this key too, so a block opened here lands open once it ends. + const disclosure = useNativeChatDisclosure( + reasoning ? nativeChatReasoningDisclosureKey(reasoning.message.id) : undefined, + false + ) + const open = reasoning !== null && disclosure.open + const labelId = useId() return ( -
- - {label} -
+ + {/* One element for every state of the line, so a screen reader hears each new label. The + trigger overlays it, rather than wrapping it, and the body sits outside it. */} +
+ + + {label} + + {reasoning ? ( + <> + + +
+ {reasoning ? ( + + + + ) : null} +
) } diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts index 9b7d2164405a..677679587328 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.test.ts @@ -172,6 +172,18 @@ describe('transcript row height estimate', () => { ) }) + it('charges a long reasoning row only for the one-line trigger it shows collapsed', () => { + const summary = 'x'.repeat(4_129) + const reasoning = estimateNativeChatRowHeight( + nativeChatRowContentMetrics(message(summary, 'reasoning')), + NO_CHROME + ) + expect(reasoning).toBe(24) + expect( + estimateNativeChatRowHeight(nativeChatRowContentMetrics(message(summary)), NO_CHROME) + ).toBeGreaterThan(900) + }) + it('reuses one derivation per message', () => { const subject = message('cached') expect(nativeChatRowContentMetrics(subject)).toBe(nativeChatRowContentMetrics(subject)) diff --git a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts index ca60ed38e0e1..9a5f052dff1c 100644 --- a/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts +++ b/src/renderer/src/components/native-chat/native-chat-row-height-estimate.ts @@ -42,6 +42,8 @@ const PROSE_MIN_LINES = 1 const USER_BUBBLE_CHROME_PX = 32 const IMAGE_STRIP_PX = 88 const TOOL_RUN_PX = 40 +/** A reasoning row draws only its `xs` trigger button until opened; opening remeasures it. */ +const COLLAPSED_REASONING_PX = 24 const SUBAGENT_ROW_PX = 32 /** The one-line head that names a subagent above its own rows. */ export const NATIVE_CHAT_SUBAGENT_SECTION_HEAD_PX = SUBAGENT_ROW_PX @@ -122,7 +124,12 @@ export function estimateNativeChatRowHeight( height = content.subagentGroupCount * SUBAGENT_ROW_PX partCount = height > 0 ? 1 : 0 } else { - height = content.textLines * typography.lineHeightPx + height = + content.role === 'reasoning' + ? content.textLines > 0 + ? COLLAPSED_REASONING_PX + : 0 + : content.textLines * typography.lineHeightPx if (content.role === 'user' && content.textLines > 0) { height += USER_BUBBLE_CHROME_PX } diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts b/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts index 0d5ca5206773..55d5514ecc3c 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-section-slots.ts @@ -110,6 +110,7 @@ export function nativeChatSubagentSectionSlots({ receipt, status: undefined, folded: false, + drawsMessage: true, turnFolds: false, turnDiff: undefined, subagentRoster: undefined, diff --git a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts index f483507c97e7..323bd0d820fc 100644 --- a/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-subagent-sections.test.ts @@ -240,6 +240,17 @@ describe("a subagent's rows live in its own section", () => { expect(new Set(slots.map(nativeChatSlotKey)).size).toBe(slots.length) }) + // The parent's live line speaks for the session's own agent only, so a child's open block draws. + it("draws a working agent's open reasoning in its section", () => { + const thinking = row('child-think', say('Comparing the two diffs'), { + ...by('task-1'), + role: 'reasoning', + state: 'running' + }) + const rows = [...transcriptWith('working').slice(0, 3), thinking] + expect(outline(slotsOf(rows, { 'task-1': true }, true))).toContain('>child-think') + }) + // The fold reads only the conversation, so a subagent's failure after the answer is its own. it("folds a settled turn to the session's own answer, not a subagent's later failure", () => { const failed = [{ type: 'text' as const, text: 'The subagent failed.', tone: 'error' as const }] diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 7841e2de1908..1d7338f21ac3 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -542,4 +542,19 @@ describe('turn-owned grouping', () => { ['C', false] ]) }) + + it('skips only the open reasoning the live line discloses, and only while it does', () => { + const reasoning = (id: string, state: 'running' | 'completed'): NativeChatMessage => ({ + ...text(id, 'Weighing two approaches', 'reasoning'), + state + }) + const live = { turnKeys: ['A', 'A', 'A'], liveTurnKey: 'A', isWorking: true } + const rows = [text('A', 'go', 'user'), reasoning('r-1', 'running'), reasoning('r-2', 'running')] + const ids = (overrides: Partial[1]>) => + build(rows, { ...live, ...overrides }).map((slot) => slot.message.id) + expect(ids({ liveReasoningId: 'r-2' })).toEqual(['A', 'r-1']) + // Nothing discloses it (a prompt took the line, or it says something else): it draws. + expect(ids({ liveReasoningId: null })).toEqual(['A', 'r-1', 'r-2']) + expect(ids({})).toEqual(['A', 'r-1', 'r-2']) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts index 1069b99d00dd..e07682b15a58 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.ts @@ -14,7 +14,10 @@ import { type NativeChatMessage } from '../../../../shared/native-chat-types' import type { NativeChatTurnStatus } from '../../../../shared/native-chat-turn-status' -import { nativeChatMessagesWaitingBehindLiveTurn } from '../../../../shared/native-chat-turn-membership' +import { + isNativeChatRowInLiveWorkingTurn, + nativeChatMessagesWaitingBehindLiveTurn +} from '../../../../shared/native-chat-turn-membership' import { nativeChatTurnBarRows } from '../../../../shared/native-chat-turn-grouping' import { nativeChatTurnFold, @@ -68,6 +71,9 @@ export type NativeChatMessageSlot = { /** This row is behind its turn's folded status row: it draws no prose and no * tool activity, only work that outlives the turn. */ folded: boolean + /** Whether the message itself draws. False when the slot is here only for its turn's bar or diff + * rollup: a folded or empty row, or the open block the live line shows. */ + drawsMessage: boolean /** Whether this row's turn hides anything, so its status row offers a caret. */ turnFolds: boolean turnDiff: NativeChatTurnDiff | undefined @@ -100,6 +106,8 @@ export type NativeChatTranscriptSlotsInput = { lifecycleWorking: boolean subagentSections?: NativeChatSubagentSections subagentChoices?: NativeChatSubagentChoices + /** The open reasoning block the live activity line discloses: its row takes no slot meanwhile. */ + liveReasoningId?: string | null } export function buildNativeChatTranscriptSlots( @@ -117,7 +125,8 @@ export function buildNativeChatTranscriptSlots( isWorking, lifecycleWorking, subagentSections: sections = NO_NATIVE_CHAT_SUBAGENT_SECTIONS, - subagentChoices: choices = NO_NATIVE_CHAT_SUBAGENT_CHOICES + subagentChoices: choices = NO_NATIVE_CHAT_SUBAGENT_CHOICES, + liveReasoningId = null } = input // One pass to decide what each row draws, then the fold over those readings — // so "is this the answer" and "does this row render prose" cannot disagree. @@ -191,24 +200,27 @@ export function buildNativeChatTranscriptSlots( // Skipping a folded row entirely is what keeps windowing honest: a counted // index the row declines to draw reserves estimated height for nothing and // opens a gap in the transcript. + const activeTurnIsWorking = isNativeChatRowInLiveWorkingTurn( + turnKey, + liveTurnKey, + isWorking || lifecycleWorking + ) const drawsRow = - receipt !== undefined || (!folded && nativeChatRowRendersContent(message.blocks)) + receipt !== undefined || + (!folded && nativeChatRowRendersContent(message.blocks) && message.id !== liveReasoningId) const roster = sectionSlots.rosterAt(message.id) if (drawsRow || status !== undefined || turnDiff !== undefined) { slots.push({ kind: 'message', message, turnKey, - // Liveness is the owning turn's, not the newest prompt's: a running turn's - // rows stay live while a newer message waits behind it. - activeTurnIsWorking: - (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) && - (isWorking || lifecycleWorking), + activeTurnIsWorking, trailingRun: index === trailingRunIndex, receipt, status: status ?? undefined, statusAbove: bar?.above === true && status !== undefined, folded, + drawsMessage: drawsRow, turnFolds: turnKey !== undefined && foldableTurnKeys.has(turnKey), turnDiff, subagentRoster: roster, diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts new file mode 100644 index 000000000000..c6b5a26812fd --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-slots.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react' +import { + nativeChatLiveLine, + type NativeChatLiveLine +} from '../../../../shared/native-chat-live-line' +import { isNativeChatRowInLiveWorkingTurn } from '../../../../shared/native-chat-turn-membership' +import { + buildNativeChatTranscriptSlots, + type NativeChatTranscriptSlot, + type NativeChatTranscriptSlotsInput +} from './native-chat-transcript-slots' + +/** The transcript's slots and its live activity line, decided together: the open reasoning block + * the line discloses takes no slot, so a row is hidden exactly while the line shows it. */ +export function useNativeChatTranscriptSlots({ + line, + ...input +}: Omit & { + line: { draws: boolean; thinking: boolean; activityText?: string | null } +}): { slots: NativeChatTranscriptSlot[]; liveLine: NativeChatLiveLine | null } { + const { + messages, + typography, + turnKeys, + liveTurnKey, + receipts, + turnStatuses, + turnDiffs, + expandedTurnKeys, + isWorking, + lifecycleWorking, + subagentSections, + subagentChoices + } = input + const { draws, thinking, activityText } = line + const liveLine = useMemo( + () => + nativeChatLiveLine({ + draws, + thinking, + activityText, + messages, + inLiveWorkingTurn: (index) => + isNativeChatRowInLiveWorkingTurn( + turnKeys[index], + liveTurnKey, + isWorking || lifecycleWorking + ) + }), + [activityText, draws, isWorking, lifecycleWorking, liveTurnKey, messages, thinking, turnKeys] + ) + const liveReasoningId = liveLine?.reasoning?.message.id ?? null + const slots = useMemo( + () => + buildNativeChatTranscriptSlots({ + messages, + typography, + turnKeys, + liveTurnKey, + receipts, + turnStatuses, + turnDiffs, + expandedTurnKeys, + isWorking, + lifecycleWorking, + subagentSections, + subagentChoices, + liveReasoningId + }), + [ + expandedTurnKeys, + isWorking, + lifecycleWorking, + liveReasoningId, + liveTurnKey, + messages, + receipts, + subagentChoices, + subagentSections, + turnDiffs, + turnKeys, + turnStatuses, + typography + ] + ) + return { slots, liveLine } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx index 0a8f6347ce9d..257e507c6974 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx @@ -57,6 +57,7 @@ function slot(id: string): NativeChatMessageSlot { receipt: undefined, status: undefined, folded: false, + drawsMessage: true, turnFolds: false, turnDiff: undefined, subagentRoster: undefined, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7ff593c472d1..fe2aff1199c7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17763,6 +17763,7 @@ "empty": "No users found" }, "native-chat": { + "reasoning": "Reasoning", "failureWords": { "theAgent": "The agent", "providerStartFailed": "{{agent}} stopped before it finished starting.", @@ -18320,6 +18321,8 @@ "queuePaused": "Queue paused", "resume": "Resume" }, + "thought": "Thought", + "thoughtForDuration": "Thought for {{duration}}", "structuredSessionHostDeclined": "Opened {{value0}} in a terminal", "structuredSessionHostDeclinedDescription": "This server can't run a {{value0}} chat in this workspace.", "structuredSessionHostUnreachable": "Could not reach {{value0}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9708bdc6ba15..cd2f8913930f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17533,6 +17533,7 @@ }, "components": { "native-chat": { + "reasoning": "Razonamiento", "contextMenu": { "copyOrcaSessionId": "Copiar ID de sesión de Orca", "orcaSessionIdCopied": "ID de sesión de Orca copiado", @@ -17951,6 +17952,8 @@ "queuePaused": "Cola en pausa", "resume": "Reanudar" }, + "thought": "Pensó", + "thoughtForDuration": "Pensó durante {{duration}}", "receipt": { "cancelled": "Cancelado", "unavailable": "Respuesta seleccionada no disponible", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 3bc1a96946ed..0ca533b16d70 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17632,6 +17632,7 @@ }, "components": { "native-chat": { + "reasoning": "Raisonnement", "contextMenu": { "copyOrcaSessionId": "Copier l'ID de session Orca", "orcaSessionIdCopied": "ID de session Orca copié", @@ -18168,6 +18169,8 @@ "queuePaused": "File d'attente en pause", "resume": "Reprendre" }, + "thought": "A réfléchi", + "thoughtForDuration": "A réfléchi pendant {{duration}}", "structuredSessionHostDeclined": "{{value0}} ouvert dans un terminal", "structuredSessionHostDeclinedDescription": "Ce serveur ne peut pas exécuter de discussion {{value0}} dans cet espace de travail.", "structuredSessionHostUnreachable": "Impossible de joindre {{value0}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fda99065b37b..7abc80e5c0b9 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17568,6 +17568,7 @@ } }, "native-chat": { + "reasoning": "推論", "contextMenu": { "copyOrcaSessionId": "Orca セッション ID をコピー", "orcaSessionIdCopied": "Orca セッション ID をコピーしました", @@ -18104,6 +18105,8 @@ "queuePaused": "キューは一時停止中です", "resume": "再開" }, + "thought": "考えました", + "thoughtForDuration": "{{duration}} 考えました", "structuredSessionHostDeclined": "{{value0}} をターミナルで開きました", "structuredSessionHostDeclinedDescription": "このサーバーはこのワークスペースで {{value0}} チャットを実行できません。", "structuredSessionHostUnreachable": "{{value0}} に接続できませんでした", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index e06aba01d150..73330f8e45fb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17568,6 +17568,7 @@ } }, "native-chat": { + "reasoning": "추론", "contextMenu": { "copyOrcaSessionId": "Orca 세션 ID 복사", "orcaSessionIdCopied": "Orca 세션 ID를 복사했습니다", @@ -18104,6 +18105,8 @@ "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개" }, + "thought": "생각함", + "thoughtForDuration": "{{duration}} 동안 생각함", "structuredSessionHostDeclined": "{{value0}}을(를) 터미널에서 열었습니다", "structuredSessionHostDeclinedDescription": "이 서버는 이 워크스페이스에서 {{value0}} 채팅을 실행할 수 없습니다.", "structuredSessionHostUnreachable": "{{value0}}에 연결할 수 없습니다", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 36efa599608a..d141cf70610e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17533,6 +17533,7 @@ }, "components": { "native-chat": { + "reasoning": "推理", "contextMenu": { "copyOrcaSessionId": "复制 Orca 会话 ID", "orcaSessionIdCopied": "已复制 Orca 会话 ID", @@ -18069,6 +18070,8 @@ "queuePaused": "队列已暂停", "resume": "继续" }, + "thought": "已思考", + "thoughtForDuration": "思考了 {{duration}}", "structuredSessionHostDeclined": "已在终端中打开 {{value0}}", "structuredSessionHostDeclinedDescription": "此服务器无法在此工作区中运行 {{value0}} 聊天。", "structuredSessionHostUnreachable": "无法连接到 {{value0}}", diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts index 10dac12611b5..6ff5e56e30c6 100644 --- a/src/shared/agent-session-journal-schemas.test.ts +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -23,6 +23,18 @@ const RESOLUTION = { // Canonical fixtures are typed: if a shape here stops compiling, the schema // audit below is validating the wrong model. const CANONICAL_BODIES: AgentJournalItemBody[] = [ + { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Inspecting the request' }] + }, + { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Inspecting the request' }], + state: 'completed', + completedAt: 2_000 + }, { kind: 'message', role: 'user', @@ -475,6 +487,14 @@ describe('thread goal fields', () => { sentAs: 'scheduled' }) ).toBe(true) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'reasoning', + blocks: [], + state: 'paused' + }) + ).toBe(true) expect( isAdmissibleAgentJournalItemBody({ kind: 'status', @@ -495,6 +515,8 @@ describe('thread goal fields', () => { for (const body of [ { kind: 'message', role: 'user', blocks: [], sentAs: 5 }, { kind: 'message', role: 'user', blocks: [], sentAs: '' }, + { kind: 'message', role: 'reasoning', blocks: [], state: '' }, + { kind: 'message', role: 'reasoning', blocks: [], completedAt: 'later' }, { kind: 'status', text: 'Goal set', threadGoal: { state: 'set' } }, { kind: 'status', diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 89118307dd69..3cbb259af36e 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -27,6 +27,7 @@ import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { AgentJournalThreadGoalStateSchema } from './agent-session-journal-thread-goal-schema' import { AgentSessionFailureFactSchema } from './agent-session-failure-fact-schema' import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union' import type { @@ -180,27 +181,12 @@ const MessageBody = z.object({ blocks: z.array(Block), // Open like roles: a send mode a newer build writes must not turn the row malformed. sentAs: z.string().min(1).optional(), - command: z.object({ name: z.string().min(1) }).optional() + command: z.object({ name: z.string().min(1) }).optional(), + // Open like `sentAs`: a state a newer host writes reads as completed, never malformed. + state: z.string().min(1).optional(), + completedAt: z.number().finite().optional() }) -const ThreadGoal = z.object({ - objective: z.string(), - status: z.string().min(1), - tokenBudget: z.number().finite().nullable(), - tokensUsed: z.number().finite(), - timeUsedSeconds: z.number().finite(), - createdAt: z.number().finite(), - updatedAt: z.number().finite() -}) - -/** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */ -const ThreadGoalState = openDiscriminatedUnion( - z.discriminatedUnion('state', [ - z.object({ state: z.literal('set'), goal: ThreadGoal }), - z.object({ state: z.literal('cleared') }) - ]) -) - /** A turn's lifecycle, as the turn item and the legacy status row both carry it. */ const TurnLifecycleFields = { turnId: z.string(), @@ -259,7 +245,7 @@ const KnownItemBody = z.discriminatedUnion('kind', [ tone: z.string().optional(), turnLifecycle: z.object(TurnLifecycleFields).optional(), providerFrame: ProviderFrame.optional(), - threadGoal: ThreadGoalState.optional(), + threadGoal: AgentJournalThreadGoalStateSchema.optional(), failure: AgentSessionFailureFactSchema.optional() }), z.object({ diff --git a/src/shared/agent-session-journal-thread-goal-schema.ts b/src/shared/agent-session-journal-thread-goal-schema.ts new file mode 100644 index 000000000000..af86675fa0de --- /dev/null +++ b/src/shared/agent-session-journal-thread-goal-schema.ts @@ -0,0 +1,22 @@ +// The journal's thread-goal transition, validated as deeply as the rest of the render model. + +import { z } from 'zod' +import { openDiscriminatedUnion } from './agent-session-journal-open-union' + +const ThreadGoal = z.object({ + objective: z.string(), + status: z.string().min(1), + tokenBudget: z.number().finite().nullable(), + tokensUsed: z.number().finite(), + timeUsedSeconds: z.number().finite(), + createdAt: z.number().finite(), + updatedAt: z.number().finite() +}) + +/** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */ +export const AgentJournalThreadGoalStateSchema = openDiscriminatedUnion( + z.discriminatedUnion('state', [ + z.object({ state: z.literal('set'), goal: ThreadGoal }), + z.object({ state: z.literal('cleared') }) + ]) +) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index ff5d53aca761..a827d65296a2 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -100,6 +100,11 @@ export type AgentJournalBoundedPayload = { export const AGENT_JOURNAL_MESSAGE_SEND_MODES = ['goal'] as const export type AgentJournalMessageSendMode = (typeof AGENT_JOURNAL_MESSAGE_SEND_MODES)[number] +/** Whether the provider is still producing a message. Persisted and open for growth: a reader + * that cannot place a value reads it as `completed`. */ +export const AGENT_JOURNAL_MESSAGE_STATES = ['running', 'completed'] as const +export type AgentJournalMessageState = (typeof AGENT_JOURNAL_MESSAGE_STATES)[number] + export type AgentJournalMessageItem = { kind: 'message' role: NativeChatRole @@ -110,6 +115,13 @@ export type AgentJournalMessageItem = { /** Present on a conversation command the user sent, such as `/compact`. The text is what the * user typed; this names the command so no reader parses it. Open like `sentAs`. */ command?: { name: string } + /** Written on reasoning rows. ABSENT MEANS UNKNOWN — an older host, or a row from before the + * field — and never reads as live. The row's `observedAt` is when it started. */ + state?: AgentJournalMessageState + /** Host clock when the host saw the message end: its own end, or the end of the turn or + * stream that cut it off. Absent only when no end was seen live — history, a crash sweep — so + * no duration is claimed. */ + completedAt?: number } export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' diff --git a/src/shared/native-chat-live-line.test.ts b/src/shared/native-chat-live-line.test.ts new file mode 100644 index 000000000000..981b9db2f23c --- /dev/null +++ b/src/shared/native-chat-live-line.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { nativeChatLiveLine } from './native-chat-live-line' +import type { NativeChatMessage } from './native-chat-types' + +const prompt: NativeChatMessage = { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text: 'Start the task' }], + timestamp: 1_000, + source: 'transcript' +} +const open: NativeChatMessage = { + id: 'r-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + timestamp: 1_000, + source: 'transcript', + state: 'running' +} +const line = (fields: { draws?: boolean; thinking?: boolean; activityText?: string | null }) => + nativeChatLiveLine({ + draws: true, + thinking: true, + messages: [prompt, open], + inLiveWorkingTurn: () => true, + ...fields + }) + +describe('the live line both clients draw', () => { + it('discloses the open block while it reads "Thinking"', () => { + expect(line({})).toEqual({ + thinking: true, + activityText: null, + reasoning: { message: open, markdown: 'Weighing two approaches' } + }) + }) + + it('discloses nothing, so nothing is hidden, unless it draws and the turn is reasoning', () => { + // A prompt the reader owes replaces the line: no line, and the row draws. + expect(line({ draws: false })).toBeNull() + expect(line({ thinking: false })).toEqual({ + thinking: false, + activityText: null, + reasoning: null + }) + }) + + // The label may read the provider's own words; the block it discloses is still the open one. + it('keeps the activity text it was handed', () => { + expect(line({ activityText: 'Summarizing the plan' })).toMatchObject({ + activityText: 'Summarizing the plan', + reasoning: { message: open } + }) + }) +}) diff --git a/src/shared/native-chat-live-line.ts b/src/shared/native-chat-live-line.ts new file mode 100644 index 000000000000..d7c10d19f20d --- /dev/null +++ b/src/shared/native-chat-live-line.ts @@ -0,0 +1,38 @@ +// The live turn's tail line, as desktop and mobile both draw it: whether it draws, what it says, +// and which open reasoning block it discloses. One value, so a block's row is hidden exactly while +// the line that shows it draws. + +import { + selectNativeChatLiveReasoning, + type NativeChatLiveReasoning +} from './native-chat-reasoning-row' +import type { NativeChatMessage } from './native-chat-types' + +export type NativeChatLiveLine = { + /** The turn is reasoning now; the label reads "Thinking" unless activity text outranks it. */ + thinking: boolean + activityText: string | null + /** The open block the line discloses; its row draws nothing meanwhile. */ + reasoning: NativeChatLiveReasoning | null +} + +export function nativeChatLiveLine(input: { + /** The running turn's tail is the activity line: nothing (a prompt the reader owes) replaces it. */ + draws: boolean + thinking: boolean + activityText?: string | null + messages: readonly NativeChatMessage[] + inLiveWorkingTurn: (index: number) => boolean +}): NativeChatLiveLine | null { + if (!input.draws) { + return null + } + return { + thinking: input.thinking, + activityText: input.activityText ?? null, + // Rows never say "Thinking", so the line discloses only while it is the one that does. + reasoning: input.thinking + ? selectNativeChatLiveReasoning(input.messages, input.inLiveWorkingTurn) + : null + } +} diff --git a/src/shared/native-chat-reasoning-row.test.ts b/src/shared/native-chat-reasoning-row.test.ts new file mode 100644 index 000000000000..76c2f422e15e --- /dev/null +++ b/src/shared/native-chat-reasoning-row.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + nativeChatReasoningDisclosureKey, + nativeChatReasoningHeadline, + nativeChatReasoningHeadlineText, + selectNativeChatLiveReasoning +} from './native-chat-reasoning-row' +import type { NativeChatMessage } from './native-chat-types' + +function row( + id: string, + role: NativeChatMessage['role'], + text: string, + fields: Partial = {} +): NativeChatMessage { + return { + id, + role, + blocks: [{ type: 'text', text }], + timestamp: 1_000, + source: 'transcript', + ...fields + } +} + +const prompt = row('user-1', 'user', 'Start the task') +const open = row('r-1', 'reasoning', 'Weighing two approaches', { state: 'running' }) +const inTurn = (): boolean => true + +describe('the open block the live line discloses', () => { + const select = (messages: NativeChatMessage[], live: (index: number) => boolean = inTurn) => + selectNativeChatLiveReasoning(messages, live)?.message ?? null + + it('is the newest root reasoning row with text, with the text it has so far', () => { + expect(selectNativeChatLiveReasoning([prompt, open], inTurn)).toEqual({ + message: open, + markdown: 'Weighing two approaches' + }) + // A host that keeps no lifecycle gets the same single live slot. + const stateless = row('r-1', 'reasoning', 'Weighing two approaches') + expect(select([prompt, stateless])).toBe(stateless) + }) + + it('is nothing when the block is blank or ended', () => { + expect(select([prompt, row('r-1', 'reasoning', ' \n', { state: 'running' })])).toBeNull() + expect(select([prompt, { ...open, state: 'completed' }])).toBeNull() + }) + + it('is nothing once a tool or the answer is newer than the block', () => { + expect(select([prompt, open, row('a-1', 'assistant', 'Here it is')])).toBeNull() + const tool = row('t-1', 'assistant', '', { + blocks: [{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'running' }] + }) + expect(select([prompt, open, tool])).toBeNull() + }) + + it('looks past notices, empty rows and rows outside the live working turn', () => { + const notice = row('s-1', 'system', 'Compacting') + const empty = row('a-0', 'assistant', '') + const waiting = row('user-2', 'user', 'Also say banana') + expect(select([prompt, open, notice, empty, waiting], (index) => index < 4)).toBe(open) + }) + + it('stops at the live turn prompt and ignores a subagent reasoning', () => { + expect(select([open, prompt])).toBeNull() + const child = row('r-2', 'reasoning', 'Child thinking', { state: 'running', agentId: 'sub-1' }) + expect(select([prompt, child])).toBeNull() + expect(select([prompt, open, child])).toBe(open) + }) + + it('keys one block the same for the line and the row, apart from tool runs', () => { + expect(nativeChatReasoningDisclosureKey('r-1')).toBe('reasoning:r-1') + }) +}) + +describe('the reasoning headline', () => { + const text = ( + fields: { state?: 'running' | 'completed'; completedAt?: number }, + live = false + ): string => + nativeChatReasoningHeadlineText( + nativeChatReasoningHeadline({ timestamp: 1_000, ...fields }, { live }) + ) + + it('reads the span the host saw, at least a second, and claims none it did not see', () => { + expect(text({ state: 'completed', completedAt: 66_000 })).toBe('Thought for 1m 5s') + expect(text({ state: 'completed', completedAt: 1_300 })).toBe('Thought for 1s') + expect(text({ state: 'completed' })).toBe('Thought') + expect(text({ state: 'running' })).toBe('Thought') + expect(text({})).toBe('Reasoning') + }) + + it('claims no past tense for a block not yet ended inside its working turn', () => { + expect(text({ state: 'running' }, true)).toBe('Reasoning') + expect(text({}, true)).toBe('Reasoning') + expect(text({ state: 'completed', completedAt: 13_000 }, true)).toBe('Thought for 12s') + }) +}) diff --git a/src/shared/native-chat-reasoning-row.ts b/src/shared/native-chat-reasoning-row.ts new file mode 100644 index 000000000000..8bcd14b6ea17 --- /dev/null +++ b/src/shared/native-chat-reasoning-row.ts @@ -0,0 +1,98 @@ +// The reasoning row, as desktop and mobile both draw it: which open block the live activity line +// discloses, and what a row's collapsed headline says. Read from host facts only — the row's start +// (`timestamp`) and the end the host saw — so every client tells the same story about one row. + +import { isRootAgentJournalItem } from './agent-session-journal-producer' +import { deriveNativeChatRowContent, nativeChatRowRendersContent } from './native-chat-row-content' +import { formatNativeChatDuration } from './native-chat-turn-status' +import type { NativeChatMessage } from './native-chat-types' + +/** An open reasoning block and the text it has so far. */ +export type NativeChatLiveReasoning = { message: NativeChatMessage; markdown: string } + +/** + * The agent's open reasoning block, with its text, when it is the newest thing its live working + * turn produced; else null. Only `nativeChatLiveLine` asks, so a block is disclosed (and its row + * hidden) only while the line that discloses it draws. + */ +export function selectNativeChatLiveReasoning( + messages: readonly NativeChatMessage[], + inLiveWorkingTurn: (index: number) => boolean +): NativeChatLiveReasoning | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message || !inLiveWorkingTurn(index)) { + continue + } + if (message.role === 'user') { + return null + } + // A notice is not newer content, and a subagent's reasoning draws in its own section. + if (message.role === 'system' || !isRootAgentJournalItem(message)) { + continue + } + if (message.role !== 'reasoning') { + if (!nativeChatRowRendersContent(message.blocks)) { + continue + } + return null + } + // Not `=== 'running'`: a host that keeps no lifecycle gets the same single live slot. + if (message.state === 'completed') { + return null + } + const { markdown } = deriveNativeChatRowContent(message.blocks) + return markdown.trim().length > 0 ? { message, markdown } : null + } + return null +} + +/** One key per reasoning block, read by the live line and the finished row alike. */ +export function nativeChatReasoningDisclosureKey(messageId: string): string { + return `reasoning:${messageId}` +} + +export type NativeChatReasoningHeadline = + /** From a host that kept no lifecycle, or not yet ended: nothing is claimed. */ + | { kind: 'reasoning' } + /** Ended, with no span the host saw. */ + | { kind: 'thought' } + | { kind: 'thoughtFor'; duration: string } + +/** `live`: the row is drawn inside its working turn or working subagent. */ +export function nativeChatReasoningHeadline( + message: Pick, + { live }: { live: boolean } +): NativeChatReasoningHeadline { + // Not ended yet, so it claims no past tense. + if (message.state === undefined || (live && message.state !== 'completed')) { + return { kind: 'reasoning' } + } + // An open row in a turn that is no longer live ended unseen, so it claims no duration. + if ( + message.state !== 'completed' || + message.completedAt === undefined || + message.timestamp === null + ) { + return { kind: 'thought' } + } + return { + kind: 'thoughtFor', + duration: formatNativeChatDuration( + Math.max(1, (message.completedAt - message.timestamp) / 1000) + ) + } +} + +/** English copy for clients without a translation catalog; desktop translates the same three. */ +const NATIVE_CHAT_REASONING_COPY = { + reasoning: 'Reasoning', + thought: 'Thought', + thoughtFor: (duration: string) => `Thought for ${duration}` +} as const + +export function nativeChatReasoningHeadlineText(headline: NativeChatReasoningHeadline): string { + return headline.kind === 'thoughtFor' + ? NATIVE_CHAT_REASONING_COPY.thoughtFor(headline.duration) + : NATIVE_CHAT_REASONING_COPY[headline.kind] +} diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index 9da8672374a2..0a34703e36ea 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -44,6 +44,8 @@ export type NativeChatToolIconName = * it names no tool category, because that row stands for a question rather * than for the call that asked it. */ | 'message-square-more' + /** The reasoning row's glyph, carried for the same aligned slot; it names no tool category. */ + | 'brain' /** Category to glyph. */ export const NATIVE_CHAT_TOOL_ICON_NAMES: Record = { diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index f00923996c40..b40290ae6002 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -237,6 +237,16 @@ export function nativeChatMessagesWaitingBehindLiveTurn( ) } +/** Whether a row belongs to the turn running now. Liveness is the owning turn's, not the newest + * prompt's: a running turn's rows stay live while a newer message waits behind it. */ +export function isNativeChatRowInLiveWorkingTurn( + turnKey: string | undefined, + liveTurnKey: string | undefined, + working: boolean +): boolean { + return working && (liveTurnKey ? turnKey === liveTurnKey : turnKey === undefined) +} + function commandTurnRunning(items: readonly AgentJournalRenderItem[]): boolean { const running = liveStructuredAgentSessionTurnScope(items) const bodyOf = (itemId: string) => items.find((item) => item.itemId === itemId)?.body diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index e8315c7c8732..46c82b7fabf9 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -14,6 +14,7 @@ import type { AgentSessionTokenUsage } from './agent-session-context-usage' import type { AgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalMessageSendMode, + AgentJournalMessageState, AgentJournalPosition, AgentJournalProducerLinkage, AgentJournalToolCallEnding, @@ -226,6 +227,10 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { parentId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** The journal row's own lifecycle; absent means unknown, never live. */ + state?: AgentJournalMessageState + /** Host clock when the row's message was seen to end; absent when no end was seen live. */ + completedAt?: number /** On a conversation command the user sent, such as `/compact`: the command it names. */ command?: { name: string } /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts index 42f8ade61ba4..ba48ab3b4812 100644 --- a/src/shared/structured-agent-session-live-turn.test.ts +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -30,6 +30,20 @@ describe('isStructuredAgentSessionThinking', () => { expect(isStructuredAgentSessionThinking([turnStart, reasoning(2)])).toBe(true) }) + it("reads a reasoning row's own state when its host keeps one", () => { + const withState = (state: 'running' | 'completed'): AgentJournalRenderItem => + item('reasoning-state', 2, { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Weighing two approaches' }], + state, + ...(state === 'completed' ? { completedAt: 3 } : {}) + }) + expect(isStructuredAgentSessionThinking([turnStart, withState('running')])).toBe(true) + // Ended reasoning stays the newest row while Claude streams a tool's input after it. + expect(isStructuredAgentSessionThinking([turnStart, withState('completed')])).toBe(false) + }) + it('is false once a tool call, a message or a diff lands after the reasoning', () => { const after = (body: AgentJournalRenderItem['body']): boolean => isStructuredAgentSessionThinking([turnStart, reasoning(2), item('after', 3, body)]) diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index 56db1518214a..f630d39ebe65 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -133,7 +133,9 @@ export function isStructuredAgentSessionThinking( continue } if (body?.kind === 'message') { - newestContentIsReasoning = body.role === 'reasoning' + // A row that says it ended is not reasoning now; a host that keeps no state says nothing. + newestContentIsReasoning = + body.role === 'reasoning' && (body.state === undefined || body.state === 'running') } else if ( body?.kind === 'tool-call' || body?.kind === 'diff' || diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 0fbb057f66e3..6eee55bbeda9 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -5,6 +5,8 @@ import { } from './agent-status-field-normalization' import { AGENT_JOURNAL_MESSAGE_SEND_MODES, + AGENT_JOURNAL_MESSAGE_STATES, + type AgentJournalMessageItem, type AgentJournalMessageSendMode, type AgentJournalRenderItem, type AgentJournalSubmission @@ -136,6 +138,20 @@ function isAgentJournalMessageSendMode(value: string): value is AgentJournalMess return AGENT_JOURNAL_MESSAGE_SEND_MODES.some((mode) => mode === value) } +/** A state this build cannot name reads as completed: a newer host's row is never live here. */ +function messageLifecycle( + body: AgentJournalMessageItem +): Pick { + const state: string | undefined = body.state + if (state === undefined) { + return {} + } + return { + state: AGENT_JOURNAL_MESSAGE_STATES.find((known) => known === state) ?? 'completed', + ...(body.completedAt !== undefined ? { completedAt: body.completedAt } : {}) + } +} + const projectedItems = new WeakMap() /** Deliberately NOT scoped by producer: every agent's rows are projected, and @@ -165,6 +181,7 @@ export function projectStructuredItemToNativeChat( // Reducer updates replace journal items, so unchanged rows keep their render caches. const projected = itemBlocks(item) const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined + const lifecycle = item.body.kind === 'message' ? messageLifecycle(item.body) : {} const command = item.body.kind === 'message' ? item.body.command : undefined const message: NativeChatMessage | null = projected ? { @@ -174,6 +191,7 @@ export function projectStructuredItemToNativeChat( blocks: projected.blocks, // A send mode this build cannot name renders as an ordinary message. ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}), + ...lifecycle, ...(command ? { command } : {}) } : null diff --git a/src/shared/structured-agent-session-reasoning-projection.test.ts b/src/shared/structured-agent-session-reasoning-projection.test.ts new file mode 100644 index 000000000000..594c29504da2 --- /dev/null +++ b/src/shared/structured-agent-session-reasoning-projection.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentJournalMessageState, + AgentJournalRenderItem +} from './agent-session-journal-types' +import { projectStructuredItemToNativeChat } from './structured-agent-session-projection' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +describe('structured reasoning projection', () => { + it('preserves reasoning text and identity for desktop and mobile consumers', () => { + const blocks = [{ type: 'text' as const, text: 'Inspecting the request' }] + expect( + projectStructuredItemToNativeChat( + item('reasoning-1', 2, { + kind: 'message', + role: 'reasoning', + blocks + }) + ) + ).toEqual({ + id: 'reasoning-1', + role: 'reasoning', + blocks, + timestamp: 2, + journalPosition: { sequence: 2, index: 0 }, + source: 'transcript' + }) + }) + + it('carries a reasoning row lifecycle, and reads a state it cannot name as completed', () => { + const blocks = [{ type: 'text' as const, text: 'Inspecting' }] + const project = (body: Pick) => + projectStructuredItemToNativeChat( + item('reasoning-2', 3, { kind: 'message', role: 'reasoning', blocks, ...body }) + ) + expect(project({ state: 'running' })).toMatchObject({ state: 'running' }) + expect(project({ state: 'completed', completedAt: 9 })).toMatchObject({ + state: 'completed', + completedAt: 9 + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a newer host's state, which this build's type cannot name. + expect(project({ state: 'paused' as AgentJournalMessageState })).toMatchObject({ + state: 'completed' + }) + expect(project({})).not.toHaveProperty('state') + }) +}) From cad8ea4fa7f8967c36de52218fc2e44fe7b7b262 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:03:04 -0700 Subject: [PATCH 03/71] fix(tabs): stop tab and close tooltips firing while grazing the strip (#25808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tabs): delay tab and close tooltips past grazing Tab title and close-icon tooltips fired near-instantly while dragging across the strip: Radix's provider-wide skipDelayDuration reopens the next tooltip with no delay for 300ms after any tooltip closes. Scope a nested TooltipProvider to the tab strip with a 700ms delay and skipDelayDuration=0, so each tab tooltip waits the full delay. * fix(tabs): drop close-button tooltip and lengthen tab tooltip delay Remove the hover tooltip from the tab close button entirely (aria-label keeps the accessible name), and raise the tab-title tooltip delay from 700ms to 1500ms so grazing or scrolling the strip no longer pops a label. Add an interaction test proving the delay is enforced and Radix's skip-delay is disabled. * fix(tabs): delay the agent-state glyph tooltip to match the strip The tab's leading agent-state dot renders its own feature tooltip with a hardcoded 200ms delay, so tabs with a live agent state (working/done/ needs-attention) popped almost instantly while plain tabs waited the full strip delay — the 'every other tab' inconsistency. Thread an optional delay through StateIndicatorTooltip/AgentStateDot and pass the tab-strip 1500ms from TerminalTabLeadingIcon; other surfaces keep 200ms. * fix(tabs): remove the agent-state glyph tooltip on tabs Hovering a tab's status glyph (spinner/check/question) popped its own tooltip after 200ms, so tabs with a live agent state popped while plain tabs stayed quiet. Remove that tooltip entirely via title={null}; the glyph stays self-describing with its aria-label, and the tab title keeps its tooltip. Reverts the now-unused delay plumbing. * reduce the time down from 1500 ms to 500 ms * fix(tabs): restore the close-tab shortcut hint with a slower delay Close buttons share one TabCloseTooltip that waits 1s, longer than the 500ms tab-title tooltip. Also merges the tooltip provider tests. --- .../tab-bar/EditorFileTabCloseButton.tsx | 66 ++++++-------- .../src/components/tab-bar/SortableTab.tsx | 75 +++++++--------- .../tab-bar/TabBar.context-menu.test.ts | 12 +-- .../TabBar.windows-shell-launch.test.ts | 4 +- ...abBar.windows-shell-remote-runtime.test.ts | 4 +- .../TabBar.windows-shell-ssh-host.test.ts | 4 +- .../components/tab-bar/TabCloseTooltip.tsx | 25 ++++++ .../tab-bar/TabStripTooltipProvider.test.tsx | 88 +++++++++++++++++++ .../tab-bar/TabStripTooltipProvider.tsx | 16 ++++ ...minalTabLeadingIcon.state-tooltip.test.tsx | 29 ++++++ .../tab-bar/TerminalTabLeadingIcon.tsx | 3 +- .../components/tab-bar/tab-bar-surface.tsx | 23 ++--- ...b-bar-windows-shell-launch-render-stubs.ts | 15 ++++ 13 files changed, 264 insertions(+), 100 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/TabCloseTooltip.tsx create mode 100644 src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx create mode 100644 src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx create mode 100644 src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx diff --git a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx index 98ef2712dbfc..d8fc28a881c1 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabCloseButton.tsx @@ -1,7 +1,6 @@ import { X } from 'lucide-react' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' +import { TabCloseTooltip } from './TabCloseTooltip' export function EditorFileTabCloseButton({ fileIsDirty, @@ -12,43 +11,32 @@ export function EditorFileTabCloseButton({ showsSelectionChrome: boolean onClose: () => void }): React.JSX.Element { - const closeShortcut = useOptionalShortcutLabel('tab.close') - const closeLabel = translate( - 'auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', - 'Close tab' - ) - return ( - - - - - - {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} - - + + + ) } diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 3f128f811fa5..763f3b7f1318 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -22,8 +22,8 @@ import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' import { useTabStripSlotProps } from './use-tab-strip-slot-props' -import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { useTabStripPointerActivation } from './tab-strip-pointer-activation' +import { TabCloseTooltip } from './TabCloseTooltip' import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' import { isTerminalTabActivityLive, @@ -174,8 +174,6 @@ export default function SortableTab({ disabled: isEditing }) const slotProps = useTabStripSlotProps(tab.id, isActive) - const closeShortcut = useOptionalShortcutLabel('tab.close') - const closeLabel = translate('auto.components.tab.bar.SortableTab.95db5f2f7d', 'Close tab') const tabTitle = tab.customTitle ?? tab.title const tabRoot = (
)} {!isEditing && !isPinned && ( - - - - - - {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} - - + } + }} + onMouseDown={(e) => { + if (e.button === 0) { + e.stopPropagation() + } + }} + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + onClose(tab.id) + }} + > + + + )}
) diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 312dccfafed2..e83f32a48521 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { expandNode } from './tab-bar-dropdown-menu-item-probe' -import { stubHeadlessReact, stubShallowSelector } from './tab-bar-windows-shell-launch-render-stubs' +import { + stubHeadlessReact, + stubShallowSelector, + stubTooltip +} from './tab-bar-windows-shell-launch-render-stubs' const appStoreSnapshot: { activeTabId: string | null @@ -181,11 +185,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ } })) -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: 'Tooltip', - TooltipContent: 'TooltipContent', - TooltipTrigger: 'TooltipTrigger' -})) +vi.mock('@/components/ui/tooltip', () => stubTooltip()) type ReactElementLike = { type: unknown diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 1e258e0fa3f2..68ffa7d52246 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -14,7 +14,8 @@ import { stubSortableTab, stubStatusDisplay, stubTabInsertion, - stubTabStripDragScroll + stubTabStripDragScroll, + stubTooltip } from './tab-bar-windows-shell-launch-render-stubs' import { appStoreSnapshot, @@ -38,6 +39,7 @@ vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton()) vi.mock('./shell-icons', () => stubShellIcons()) vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface()) vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu()) +vi.mock('@/components/ui/tooltip', () => stubTooltip()) describe('TabBar PowerShell launch wiring', () => { beforeEach(() => { diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts index 3e4609a8bacf..784d888f82d2 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-remote-runtime.test.ts @@ -14,7 +14,8 @@ import { stubSortableTab, stubStatusDisplay, stubTabInsertion, - stubTabStripDragScroll + stubTabStripDragScroll, + stubTooltip } from './tab-bar-windows-shell-launch-render-stubs' import { appStoreSnapshot, @@ -38,6 +39,7 @@ vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton()) vi.mock('./shell-icons', () => stubShellIcons()) vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface()) vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu()) +vi.mock('@/components/ui/tooltip', () => stubTooltip()) describe('TabBar PowerShell launch wiring', () => { beforeEach(() => { diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts index 8254dec2e951..1e4788f21547 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-ssh-host.test.ts @@ -14,7 +14,8 @@ import { stubSortableTab, stubStatusDisplay, stubTabInsertion, - stubTabStripDragScroll + stubTabStripDragScroll, + stubTooltip } from './tab-bar-windows-shell-launch-render-stubs' import { appStoreSnapshot, @@ -38,6 +39,7 @@ vi.mock('./QuickLaunchButton', () => stubQuickLaunchButton()) vi.mock('./shell-icons', () => stubShellIcons()) vi.mock('@/lib/focus-terminal-tab-surface', () => stubFocusTerminalTabSurface()) vi.mock('@/components/ui/dropdown-menu', () => stubDropdownMenu()) +vi.mock('@/components/ui/tooltip', () => stubTooltip()) describe('TabBar PowerShell launch wiring', () => { beforeEach(() => { diff --git a/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx b/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx new file mode 100644 index 000000000000..519e10d358d6 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabCloseTooltip.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { translate } from '@/i18n/i18n' +import { TAB_TOOLTIP_DELAY_MS } from './TabStripTooltipProvider' + +// Why: the close hint is secondary to the tab title, so it waits longer. +export const TAB_CLOSE_TOOLTIP_DELAY_MS = TAB_TOOLTIP_DELAY_MS + 500 + +export function TabCloseTooltip({ children }: { children: ReactNode }): React.JSX.Element { + const closeShortcut = useOptionalShortcutLabel('tab.close') + const closeLabel = translate( + 'auto.components.tab.bar.EditorFileTabCloseButton.a768f428f1', + 'Close tab' + ) + + return ( + + {children} + + {closeShortcut ? `${closeLabel} (${closeShortcut})` : closeLabel} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx new file mode 100644 index 000000000000..6fb89da330f9 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { TAB_TOOLTIP_DELAY_MS, TabStripTooltipProvider } from './TabStripTooltipProvider' + +function renderTooltip(label: string, tip: string): void { + render( + + + + + + {tip} + + + ) +} + +describe('TabStripTooltipProvider', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + cleanup() + }) + + it('holds a tab tooltip until the full delay elapses', () => { + renderTooltip('tab label', 'tab tooltip') + fireEvent.pointerMove(screen.getByRole('button', { name: 'tab label' }), { + pointerType: 'mouse' + }) + + act(() => { + vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS - 1) + }) + expect(screen.queryByText('tab tooltip')).toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(screen.getByText('tab tooltip')).toBeTruthy() + }) + + it('does not let a recently-closed tooltip skip the delay on the next tab', () => { + render( + + + + + + first tip + + + + + + second tip + + + ) + + const first = screen.getByRole('button', { name: 'first tab' }) + const second = screen.getByRole('button', { name: 'second tab' }) + + fireEvent.pointerMove(first, { pointerType: 'mouse' }) + act(() => { + vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS) + }) + expect(screen.getByText('first tip')).toBeTruthy() + + fireEvent.pointerLeave(first) + fireEvent.pointerMove(second, { pointerType: 'mouse' }) + + // Radix's default would open this one instantly for 300ms after a close. + act(() => { + vi.advanceTimersByTime(TAB_TOOLTIP_DELAY_MS - 1) + }) + expect(screen.queryByText('second tip')).toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(screen.getByText('second tip')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx new file mode 100644 index 000000000000..3cdbe55b721a --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabStripTooltipProvider.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' +import { TooltipProvider } from '@/components/ui/tooltip' + +// Why: longer than the app-wide 400ms so a pointer merely passing over the strip never pops a title. +export const TAB_TOOLTIP_DELAY_MS = 500 + +export function TabStripTooltipProvider({ children }: { children: ReactNode }): React.JSX.Element { + // Why: skipDelayDuration=0 keeps the full delay after a tooltip closes. Radix + // otherwise opens the next tab's label instantly for 300ms after any close, + // which is what makes dragging across the strip fire tooltips back to back. + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx new file mode 100644 index 000000000000..9b9138548c12 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.state-tooltip.test.tsx @@ -0,0 +1,29 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' + +const agentDotMock = vi.hoisted(() => vi.fn((_props: Record) => null)) + +vi.mock('@/components/AgentStateDot', () => ({ + AgentStateDot: (props: Record) => { + agentDotMock(props) + return null + } +})) + +import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' + +describe('TerminalTabLeadingIcon agent-state glyph', () => { + it('renders the state glyph without a hover tooltip', () => { + renderToStaticMarkup( + + ) + + expect(agentDotMock).toHaveBeenCalledWith(expect.objectContaining({ title: null })) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx index 5251b3e75bb7..bfa628b72ced 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.tsx @@ -76,7 +76,8 @@ export function TerminalTabLeadingIcon({ data-agent-activity-status={activityStatus} className="mr-1 inline-flex shrink-0 items-center gap-1" > - + {/* Why: the state glyph is self-describing and the tab title already has a tooltip; hover here stays quiet. */} + {/* Why: status and identity answer different questions. Keep the agent logo beside the state glyph so parallel tabs remain scannable. */} {agent ? : null} diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index 693691a5b80b..9e2cc2c5c970 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -13,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { QuickLaunchAgentMenuItems } from './QuickLaunchButton' import TabBarCreateEntry from './TabBarCreateEntry' import { TabStripScrollIndicator } from './TabStripScrollIndicator' +import { TabStripTooltipProvider } from './TabStripTooltipProvider' import { getTabStripScrollMaskClassName } from './tab-strip-scroll-metrics' import type { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import type { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' @@ -159,16 +160,18 @@ export function renderTabBarSurface({ .filter(Boolean) .join(' ')} > - {renderedItems} - {clientHostedBrowserRows.length > 0 ? ( - - ) : null} + + {renderedItems} + {clientHostedBrowserRows.length > 0 ? ( + + ) : null} +
{ } } +// Tooltip primitives render to inert descriptors so the probe never invokes a +// hook-using provider/trigger under the headless React stub. +export function stubTooltip(): Record { + const passthrough = (type: string) => + function TooltipPrimitiveStub(props: { children?: unknown }) { + return { type, props } + } + return { + TooltipProvider: passthrough('TooltipProvider'), + Tooltip: passthrough('Tooltip'), + TooltipTrigger: passthrough('TooltipTrigger'), + TooltipContent: passthrough('TooltipContent') + } +} + export function stubStatusDisplay(): Record { return { buildStatusMap: () => new Map() } } From 842c6c667a86d66d55b8cc3b2486ea6be5c56fcc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:24:51 -0700 Subject: [PATCH 04/71] fix(jira): tolerate malformed nested Jira status fields (#25656) * fix(jira): reject malformed nested status data * fix(jira): tolerate malformed status fields * fix(jira): tie status parsing to the shared types Allowed auth types and credential protections are coverage records over JiraAuthType and SecretAtRestProtection, and the parsed status must list every JiraConnectionStatus key, so a union or field change in the shared types fails tsc instead of being silently dropped by this parser. Adds a populated multi-site round-trip fixture and keeps connected statuses with a malformed viewer or sites value connected. * fix(jira): require every site and viewer field in status parsing Site and viewer parsing now lists every JiraSite and JiraViewer key, like the top-level status, so a new optional field fails tsc instead of being dropped. --- .../runtime-jira-connection-status.test.ts | 120 +++++++++++++++++- .../runtime/runtime-jira-connection-status.ts | 99 +++++++++++++-- 2 files changed, 207 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/runtime/runtime-jira-connection-status.test.ts b/src/renderer/src/runtime/runtime-jira-connection-status.test.ts index 29f92e924dca..05669334e7f5 100644 --- a/src/renderer/src/runtime/runtime-jira-connection-status.test.ts +++ b/src/renderer/src/runtime/runtime-jira-connection-status.test.ts @@ -6,9 +6,7 @@ describe('parseJiraConnectionStatus', () => { ['undefined', undefined], ['null', null], ['an array', []], - ['a non-boolean connected', { connected: 'yes', viewer: null }], - ['a non-object viewer', { connected: true, viewer: 'me' }], - ['non-array sites', { connected: true, viewer: null, sites: {} }] + ['a non-boolean connected', { connected: 'yes', viewer: null }] ])('reads %s as disconnected', (_label, value) => { expect(parseJiraConnectionStatus(value)).toEqual({ connected: false, viewer: null }) }) @@ -24,10 +22,126 @@ describe('parseJiraConnectionStatus', () => { expect(parseJiraConnectionStatus(status)).toEqual(status) }) + it('round-trips a populated multi-site status unchanged', () => { + const status = { + connected: true, + viewer: { + accountId: 'account-cloud', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://avatar.example/ada.png' + }, + sites: [ + { + id: 'site-server', + siteUrl: 'https://jira.internal.example', + email: '', + displayName: 'Ada (PAT)', + accountId: '', + authType: 'server' + }, + { + id: 'site-cloud', + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Ada Lovelace', + accountId: 'account-cloud', + authType: 'cloud' + }, + { + id: 'site-legacy', + siteUrl: 'https://legacy.atlassian.net', + email: 'ada@legacy.example', + displayName: 'Ada (legacy)', + accountId: 'account-legacy' + } + ], + activeSiteId: 'site-cloud', + selectedSiteId: 'all', + credentialError: 'Could not decrypt the stored Jira token.', + credentialProtection: 'plaintext' + } + expect(parseJiraConnectionStatus(status)).toEqual(status) + }) + + it.each([ + ['a non-object viewer', { connected: true, viewer: 'me' }], + ['a non-array sites value', { connected: true, viewer: null, sites: {} }] + ])('keeps a connected status with %s', (_label, value) => { + expect(parseJiraConnectionStatus(value)).toEqual({ connected: true, viewer: null }) + }) + it('fills a missing viewer with null', () => { expect(parseJiraConnectionStatus({ connected: false })).toEqual({ connected: false, viewer: null }) }) + + it('keeps a connected status while dropping malformed nested values', () => { + expect( + parseJiraConnectionStatus({ + connected: true, + viewer: {}, + sites: [ + { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: null, + displayName: 'Example', + accountId: 'account-1' + }, + null + ], + activeSiteId: 1, + selectedSiteId: 'site-1', + credentialError: { message: 'nope' }, + credentialProtection: 'future-value' + }) + ).toEqual({ + connected: true, + viewer: null, + sites: [ + { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: '', + displayName: 'Example', + accountId: 'account-1' + } + ], + selectedSiteId: 'site-1' + }) + }) + + it('keeps a site with an unknown auth type connected', () => { + expect( + parseJiraConnectionStatus({ + connected: true, + viewer: null, + sites: [ + { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: 'a@example.com', + displayName: 'Example', + accountId: 'account-1', + authType: 'datacenter' + } + ] + }) + ).toEqual({ + connected: true, + viewer: null, + sites: [ + { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: 'a@example.com', + displayName: 'Example', + accountId: 'account-1' + } + ] + }) + }) }) diff --git a/src/renderer/src/runtime/runtime-jira-connection-status.ts b/src/renderer/src/runtime/runtime-jira-connection-status.ts index cb1d5a72637a..ad75cb4cfc88 100644 --- a/src/renderer/src/runtime/runtime-jira-connection-status.ts +++ b/src/renderer/src/runtime/runtime-jira-connection-status.ts @@ -1,20 +1,101 @@ -import type { JiraConnectionStatus } from '../../../shared/jira-types' +import type { + JiraAuthType, + JiraConnectionStatus, + JiraSite, + JiraViewer +} from '../../../shared/jira-types' +import type { SecretAtRestProtection } from '../../../shared/secret-at-rest-protection' + +// Coverage records: tsc fails when the shared union gains or loses an arm. +const JIRA_AUTH_TYPES = { cloud: true, server: true } as const satisfies Readonly< + Record +> +const SECRET_AT_REST_PROTECTIONS = { sealed: true, plaintext: true } as const satisfies Readonly< + Record +> + +// Every key of the status, site and viewer must be listed, so a newly added field cannot be dropped silently. +type EveryKey = { [K in keyof Required]: T[K] } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -// Why: the paired web client has no Jira preload, so its fallback proxy resolves -// undefined; any reply the store's status comparisons can't read is disconnected. -export function parseJiraConnectionStatus(value: unknown): JiraConnectionStatus { +function isJiraAuthType(value: unknown): value is JiraAuthType { + return typeof value === 'string' && Object.hasOwn(JIRA_AUTH_TYPES, value) +} + +function isSecretAtRestProtection(value: unknown): value is SecretAtRestProtection { + return typeof value === 'string' && Object.hasOwn(SECRET_AT_REST_PROTECTIONS, value) +} + +function parseJiraViewer(value: unknown): JiraViewer | null { + if ( + !isRecord(value) || + typeof value.accountId !== 'string' || + typeof value.displayName !== 'string' + ) { + return null + } + const viewer: EveryKey = { + accountId: value.accountId, + displayName: value.displayName, + email: typeof value.email === 'string' || value.email === null ? value.email : null, + avatarUrl: typeof value.avatarUrl === 'string' ? value.avatarUrl : undefined + } + return viewer +} + +function parseJiraSite(value: unknown): JiraSite | null { if ( !isRecord(value) || - typeof value.connected !== 'boolean' || - !(value.viewer === undefined || value.viewer === null || isRecord(value.viewer)) || - !(value.sites === undefined || Array.isArray(value.sites)) + typeof value.id !== 'string' || + typeof value.siteUrl !== 'string' || + typeof value.displayName !== 'string' || + typeof value.accountId !== 'string' ) { + return null + } + const site: EveryKey = { + id: value.id, + siteUrl: value.siteUrl, + email: typeof value.email === 'string' ? value.email : '', + displayName: value.displayName, + accountId: value.accountId, + authType: isJiraAuthType(value.authType) ? value.authType : undefined + } + return site +} + +function parseJiraSites(value: unknown): JiraSite[] | undefined { + return Array.isArray(value) + ? value.flatMap((site) => { + const parsed = parseJiraSite(site) + return parsed ? [parsed] : [] + }) + : undefined +} + +function parseOptionalSiteId(value: unknown): string | null | undefined { + return value === null || typeof value === 'string' ? value : undefined +} + +// Why: a missing reply (the paired web client's fallback) or a malformed nested field from another host version must not crash readers or hide a real connection. +export function parseJiraConnectionStatus(value: unknown): JiraConnectionStatus { + if (!isRecord(value) || typeof value.connected !== 'boolean') { return { connected: false, viewer: null } } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `connected`, `viewer` and `sites`, the fields status readers dereference, are checked above. - return { ...value, viewer: value.viewer ?? null } as JiraConnectionStatus + const status: EveryKey = { + connected: value.connected, + viewer: parseJiraViewer(value.viewer), + sites: parseJiraSites(value.sites), + activeSiteId: parseOptionalSiteId(value.activeSiteId), + selectedSiteId: parseOptionalSiteId(value.selectedSiteId), + credentialError: typeof value.credentialError === 'string' ? value.credentialError : undefined, + credentialProtection: + value.credentialProtection === null || isSecretAtRestProtection(value.credentialProtection) + ? value.credentialProtection + : undefined + } + return status } From ccab2a31102da26b8096934cd3cae87dfcac5f51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:57:15 -0700 Subject: [PATCH 05/71] fix(native-chat): a long reply keeps its Working bar and Stop after a reload, from the host's turn (#25751) * fix(native-chat): the chat reads whether a turn runs from the host, not the loaded history page * fix(native-chat): the Working bar, card cancel and phone Stop follow the host's turn when its rows are not loaded * fix(native-chat): keep the phone's Stop as it was, naming the host's running turn Drops the conversation-wide phone Stop: the phone shows Stop only once a turn is running, so a Stop naming no turn could not be reached and only changed the ordinary Stop's request. Stop and card cancel still name the turn the host says is running. * fix(native-chat): the host's turn answer never outlives its host, and a half-loaded turn shows no edit totals - A batch carrying rows restates the host's newest turn record, so one without it (an older host after a downgrade) drops back to the loaded rows instead of holding the last answer; the event coalescer merges by the same rule. - A turn whose record is above the loaded rows is grouped only while it runs, and its loaded tail gets no "N changed files" total, which would undercount. - "Thinking" reads the host's running turn too, so it shows when the turn's record is not loaded. - The wire doc for observedAt says what the journal stores: creation time. * refactor(native-chat): totals per turn come from their own hook, keeping the transcript list under its line limit Also drops the chat zoom prop main removed from the unloaded live turn test. --- .../mobile-structured-agent-session-cancel.ts | 4 +- ...obile-native-chat-turn-disclosure.test.tsx | 43 ++++ ...tured-agent-session-prompt-cancel.test.tsx | 67 ++++++ .../use-mobile-structured-agent-session.ts | 8 +- ...use-mobile-structured-agent-turn-timing.ts | 12 +- ...use-mobile-structured-send-with-outcome.ts | 4 +- ...tructured-journal-subagent-readers.test.ts | 2 +- .../agent-session-history-page.ts | 18 +- .../agent-session-subscriber-catch-up.ts | 1 + ...red-agent-session-live-turn-window.test.ts | 214 ++++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 44 ++-- ...hatMessageList.unloaded-live-turn.test.tsx | 119 ++++++++++ .../NativeChatStructuredSession.tsx | 1 + .../native-chat/native-chat-turn-diffs.ts | 6 +- .../use-native-chat-transcript-projection.ts | 10 +- .../native-chat/use-native-chat-turn-diffs.ts | 28 +++ .../use-native-chat-turn-membership.ts | 17 +- ...use-structured-agent-session-stop.test.tsx | 52 ++++- ...tructured-agent-session-transport-state.ts | 8 +- .../use-structured-agent-session.ts | 2 + .../use-structured-agent-turn-timing.ts | 7 +- .../agent-session-turn-completion-wire.ts | 49 ++++ src/shared/agent-session-wire.ts | 74 ++---- .../native-chat-turn-membership.test.ts | 33 +++ src/shared/native-chat-turn-membership.ts | 32 ++- ...structured-agent-session-coalescer.test.ts | 54 +++++ .../structured-agent-session-coalescer.ts | 6 +- ...structured-agent-session-item-retention.ts | 28 ++- ...structured-agent-session-live-turn.test.ts | 59 +++-- .../structured-agent-session-live-turn.ts | 64 +++++- .../structured-agent-session-reducer.test.ts | 63 +++++- .../structured-agent-session-reducer.ts | 34 +-- .../structured-agent-session-turn-timing.ts | 20 +- 33 files changed, 1021 insertions(+), 162 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts create mode 100644 src/shared/agent-session-turn-completion-wire.ts diff --git a/mobile/src/session/mobile-structured-agent-session-cancel.ts b/mobile/src/session/mobile-structured-agent-session-cancel.ts index 3a3286510529..49232afeb326 100644 --- a/mobile/src/session/mobile-structured-agent-session-cancel.ts +++ b/mobile/src/session/mobile-structured-agent-session-cancel.ts @@ -1,7 +1,7 @@ import type { AgentSessionCancelResult } from '../../../src/shared/agent-session-wire' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' -import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import type { RpcClient } from '../transport/rpc-client' import { requestStructuredAgentSessionMutation, @@ -37,7 +37,7 @@ export async function requestMobileStructuredAgentSessionCancel(args: { }): Promise { const { client, enabled, inFlight, onSendError, sessionId, stateRef } = args const current = stateRef.current - const turnId = activeStructuredAgentSessionTurnId(current.items) + const turnId = runningStructuredAgentSessionTurnId(current) if (!client || !sessionId || !enabled || current.fence === null || !turnId) { onSendError('Stop not sent') return false diff --git a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx index 5a29875b08c5..766190a6bf07 100644 --- a/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-turn-disclosure.test.tsx @@ -767,6 +767,49 @@ describe('useMobileNativeChatTurnDisclosure', () => { expect(rows.map((row) => row.activeTurnIsWorking)).toEqual([false, false, true]) expect(rows[0].turnStatus?.workedSeconds).toBe(4) }) + + it('draws the live bar on a turn whose record and opening message are not loaded', () => { + // The newest rows of a long turn; its record is above the page, named only by the host. + const items: AgentJournalRenderItem[] = ['a300', 'a301'].map((itemId, index) => ({ + itemId, + revision: 0, + sequence: 300 + index, + observedAt: 300 + index, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: itemId }] }, + turnScope: { kind: 'turn', turnItemId: 'turn-record' } + })) + const messages: NativeChatMessage[] = items.map((item) => ({ + ...userMessage(item.itemId), + role: 'assistant' + })) + const rowsWith = (latestTurn: NativeChatTurnJournal['latestTurn']) => { + act(() => { + renderer = create( + createElement(Harness, { + messages, + enabled: true, + workingStartedAt: 1_000, + turnJournal: { items, submissions: [], latestTurn } + }) + ) + }) + const disclosure = renderer!.root.findByType('result').props.disclosure + const rows = messages.map((message, index) => disclosure.resolveRow(index, message)) + act(() => renderer?.unmount()) + return rows + } + + const hosted = rowsWith({ + itemId: 'turn-record', + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' } + }) + expect(hosted[0]?.turnStatus).toMatchObject({ startedAt: 1_000 }) + expect(hosted.map((row) => row.activeTurnIsWorking)).toEqual([true, true]) + + // From the loaded rows alone, nothing names the turn, so no bar draws. + expect(rowsWith(undefined).map((row) => row.turnStatus)).toEqual([null, null]) + }) }) describe('the open reasoning block the live line discloses', () => { diff --git a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx index 0bb7a8f74285..9c9ced64fe3f 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-prompt-cancel.test.tsx @@ -224,6 +224,49 @@ describe('mobile structured prompt cancellation', () => { ) }) + it("stops the host's running turn when its record is not loaded", async () => { + state = { + ...state, + hasOlder: true, + items: [pendingApproval()], + latestTurn: { + itemId: 'turn-status', + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running', startedAt: 1 } + } + } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.isWorking).toBe(true) + await act(async () => { + expect(await hook.cancelPrompt()).toBe(true) + }) + expect(mocks.sendRequest).toHaveBeenCalledWith( + 'agentSession.cancel', + expect.objectContaining({ + turnId: 'turn-1', + prompt: { itemId: 'approval-1', expectedRevision: 4 } + }), + expect.any(Object) + ) + }) + + it('reads a turn the host ended as ended, though its running row is still loaded', () => { + state = { + ...state, + latestTurn: { + itemId: 'turn-status', + observedAt: 1, + turn: { turnId: 'turn-1', state: 'completed', startedAt: 1, completedAt: 2 } + } + } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.turnId).toBeNull() + }) + it('uses the rendered prompt identity when the journal changes before tap', async () => { act(() => { renderer = create(createElement(Harness, { promptCancelSupported: true })) @@ -243,4 +286,28 @@ describe('mobile structured prompt cancellation', () => { expect.any(Object) ) }) + + it("names the host's running turn for a plain Stop when its record is not loaded", async () => { + state = { + ...state, + hasOlder: true, + items: [], + latestTurn: { + itemId: 'turn-status', + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running' } + } + } + act(() => { + renderer = create(createElement(Harness, { promptCancelSupported: true })) + }) + expect(hook.turnId).toBe('turn-1') + await act(async () => { + hook.cancel() + }) + const call = mocks.sendRequest.mock.calls.find(([method]) => method === 'agentSession.cancel') + expect(call).toBeDefined() + expect(call?.[1]).toMatchObject({ turnId: 'turn-1' }) + expect(call?.[1]).not.toHaveProperty('prompt') + }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 1e1658511bce..85b0d65298fd 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -7,8 +7,8 @@ import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-n import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { - activeStructuredAgentSessionTurnId, - isStructuredAgentSessionThinking + isStructuredAgentSessionThinking, + runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { selectStructuredAgentTurnActivity } from '../../../src/shared/native-chat-turn-activity' import { @@ -177,14 +177,14 @@ export function useMobileStructuredAgentSession(args: { }), [transcriptItems, state.submissions] ) - const turnId = activeStructuredAgentSessionTurnId(state.items) + const turnId = runningStructuredAgentSessionTurnId(state) const turnTiming = useMobileStructuredAgentTurnTiming( { ...state, items: transcriptItems }, turnId ) const activityText = selectStructuredAgentTurnActivity(state.items, turnId, state.activity)?.text ?? null - const thinking = isStructuredAgentSessionThinking(state.items) + const thinking = isStructuredAgentSessionThinking(state) const turnIndicator = useMemo(() => ({ thinking, activityText }), [thinking, activityText]) const status = state.status === 'idle' ? 'idle' : state.status const approvalPrompt = useMemo( diff --git a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts index 34f0df7edff2..a701cdbc21d1 100644 --- a/mobile/src/session/use-mobile-structured-agent-turn-timing.ts +++ b/mobile/src/session/use-mobile-structured-agent-turn-timing.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../src/shared/native-chat-turn-status' +import type { AgentSessionLatestTurn } from '../../../src/shared/agent-session-wire' import type { NativeChatTurnJournal } from '../../../src/shared/native-chat-turn-membership' import type { StructuredAgentHostClock } from '../../../src/shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../src/shared/structured-agent-session-turn-timing' @@ -19,10 +20,12 @@ export function useMobileStructuredAgentTurnTiming( { items, submissions, + latestTurn, hostClock }: { items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] + latestTurn?: AgentSessionLatestTurn | null hostClock?: StructuredAgentHostClock | null }, turnId: string | null @@ -33,10 +36,13 @@ export function useMobileStructuredAgentTurnTiming( workingStartedAt: number | null } { const { settledTurns, runningTiming } = useMemo( - () => selectStructuredAgentTurnBars(items, submissions, turnId), - [items, submissions, turnId] + () => selectStructuredAgentTurnBars(items, submissions, turnId, latestTurn), + [items, submissions, turnId, latestTurn] + ) + const turnJournal = useMemo( + () => ({ items, submissions, latestTurn }), + [items, submissions, latestTurn] ) - const turnJournal = useMemo(() => ({ items, submissions }), [items, submissions]) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of // a new turn already counts from the right instant. diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 613dd14fe084..d08e3365b0c2 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -4,7 +4,7 @@ // host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' -import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { runningStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { structuredAgentSessionSendBody, @@ -95,7 +95,7 @@ export function useMobileStructuredSendWithOutcome(args: { ...controller }, canRun: () => - !activeStructuredAgentSessionTurnId(stateRef.current.items) && + !runningStructuredAgentSessionTurnId(stateRef.current) && !stateRef.current.items.some( (item) => pendingStructuredApproval(item) || pendingStructuredQuestion(item) ), diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 87b888ec1a08..a4820c6a70f3 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -163,7 +163,7 @@ describe("a Codex subagent's rows on the parent's surfaces", () => { summary: ['Reading the diff'] }) - expect(isStructuredAgentSessionThinking(await items())).toBe(false) + expect(isStructuredAgentSessionThinking({ items: await items() })).toBe(false) }) it("does not show the child's compaction as the parent's activity line", async () => { diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index ba9ee8a54533..b1667daa512c 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -9,6 +9,7 @@ import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { latestStructuredAgentSessionTurn } from '../../../shared/structured-agent-session-live-turn' import type { AgentJournalCursor, AgentJournalRenderItem, @@ -21,6 +22,7 @@ import { type AgentSessionHistoryPage, type AgentSessionHistoryRequest, type AgentSessionHistoryResult, + type AgentSessionLatestTurn, type AgentSessionSubagentRosterEntry } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -308,6 +310,20 @@ function buildPage(input: { liveCursor: input.snapshot.cursor, hasOlder: input.hasOlder, hasNewer: input.hasNewer, - ...(input.subagentRoster === undefined ? {} : { subagentRoster: input.subagentRoster }) + ...(input.subagentRoster === undefined ? {} : { subagentRoster: input.subagentRoster }), + // From the whole timeline, never the page: a long turn's record sits below any window. + latestTurn: snapshotLatestTurn(input.snapshot) } } + +// A catch-up run pages one snapshot many times; its scan back to the turn record is paid once. +const latestTurnBySnapshot = new WeakMap() + +function snapshotLatestTurn(snapshot: AgentJournalSnapshot): AgentSessionLatestTurn | null { + let latest = latestTurnBySnapshot.get(snapshot) + if (latest === undefined) { + latest = latestStructuredAgentSessionTurn(snapshot.items) + latestTurnBySnapshot.set(snapshot, latest) + } + return latest +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts index 37ec48ad9591..df98d8ba902b 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -93,6 +93,7 @@ export function deliverToSubscriber( submissions: page.submissions }, fence: subscriber.fence, + ...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}), ...shared }, // On a multi-page catch-up the draft list rides only the final page, or a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts new file mode 100644 index 000000000000..75c88bcf480e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-live-turn-window.test.ts @@ -0,0 +1,214 @@ +// A long turn's record keeps the place it opened at when revised, so a bounded page leaves it out. +// Whether a turn runs comes from the host's whole-journal record, never the loaded rows. + +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 { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalTurnLifecycle, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + activeStructuredAgentSessionTurnId, + runningStructuredAgentSessionTurnId +} from '../../../shared/structured-agent-session-live-turn' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession, + type StructuredAgentSessionState +} from '../../../shared/structured-agent-session-reducer' +import { selectStructuredAgentTurnBars } from '../../../shared/structured-agent-session-turn-timing' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from './agent-session-history-page' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') +} + +const journals = createTrackedJournalOpener() +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-live-turn-window-')) + journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function turnRecord(turnId: string, ordinal = 0): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId, ordinal } +} + +async function writeTurn(turnId: string, turn: Omit) { + await journal.appendItem( + turnRecord(turnId), + { kind: 'turn', turnId, ...turn }, + { fence: 1, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turnRecord(turnId)) } } + ) +} + +/** A running turn whose record sits `rows` rows above the journal head. */ +async function runLongTurn(turnId: string, rows: number): Promise { + await writeTurn(turnId, { state: 'running', startedAt: 1_000, requestedAt: 900 }) + const turnScope = { kind: 'turn' as const, turnItemId: agentJournalItemKey(turnRecord(turnId)) } + for (let ordinal = 1; ordinal <= rows; ordinal += 1) { + await journal.appendItem( + turnRecord(turnId, ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `row ${ordinal}` }] }, + { fence: 1, turnScope } + ) + } +} + +function hydrate(): StructuredAgentSessionState { + const page = readAgentSessionHydrationPage(journal, 1) + return reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 'session-1', page, fence: 1 } + }) +} + +/** A live subscriber attached at `state`'s cursor; returns a pump that applies what it was sent. */ +function subscribeFrom(state: StructuredAgentSessionState) { + const events: AgentSessionSubscribeEvent[] = [] + const subscribers = new AgentSessionSubscribers() + subscribers.open({ + id: 'client-1', + sessionId: 'session-1', + journal, + fence: 1, + emit: (event) => events.push(event), + ...(state.cursor ? { cursor: state.cursor } : {}) + }) + let current = state + return { + subscribers, + events, + apply(): StructuredAgentSessionState { + for (const event of events.splice(0)) { + current = reduceStructuredAgentSession(current, { type: 'event', event }) + } + return current + } + } +} + +describe('a running turn whose record is older than the loaded page', () => { + it('is running in the chat, as it is on the host', async () => { + await runLongTurn('turn-1', 250) + const state = hydrate() + + expect(journal.activeTurnId()).toBe('turn-1') + expect(state.hasOlder).toBe(true) + // What every client read before: the record is off the page, so the loaded rows said idle. + expect(activeStructuredAgentSessionTurnId(state.items)).toBeNull() + expect(runningStructuredAgentSessionTurnId(state)).toBe('turn-1') + }) + + it('counts Working from the host record when the record is not loaded', async () => { + await runLongTurn('turn-1', 250) + const state = hydrate() + const turnId = runningStructuredAgentSessionTurnId(state) + + const { runningTiming } = selectStructuredAgentTurnBars( + state.items, + state.submissions, + turnId, + state.latestTurn + ) + expect(runningTiming).toMatchObject({ state: 'running', startedAt: 1_000, requestedAt: 900 }) + }) + + it('stops reading as running when the turn ends off the page', async () => { + await runLongTurn('turn-1', 250) + const live = subscribeFrom(hydrate()) + + await writeTurn('turn-1', { state: 'completed', startedAt: 1_000, completedAt: 2_000 }) + live.subscribers.publish('session-1', journal) + const state = live.apply() + + // The completing revision keeps its old position, so the window never admits it. + expect( + state.items.some((item) => item.itemId === agentJournalItemKey(turnRecord('turn-1'))) + ).toBe(false) + expect(runningStructuredAgentSessionTurnId(state)).toBeNull() + expect(state.latestTurn?.turn).toMatchObject({ turnId: 'turn-1', state: 'completed' }) + }) + + it('follows the next turn as it opens', async () => { + await runLongTurn('turn-1', 250) + await writeTurn('turn-1', { state: 'completed', startedAt: 1_000, completedAt: 2_000 }) + const live = subscribeFrom(hydrate()) + + await writeTurn('turn-2', { state: 'running', startedAt: 3_000 }) + live.subscribers.publish('session-1', journal) + + expect(runningStructuredAgentSessionTurnId(live.apply())).toBe('turn-2') + }) + + it('is not undone by an older page read while live frames moved on', async () => { + await runLongTurn('turn-1', 250) + const before = hydrate() + const older = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: { epoch: before.epoch ?? '', sequence: before.items[0]?.sequence ?? 0 }, + limit: 40 + }) + const live = subscribeFrom(before) + await writeTurn('turn-1', { state: 'completed', startedAt: 1_000, completedAt: 2_000 }) + live.subscribers.publish('session-1', journal) + const ended = live.apply() + + if (!older.ok) { + throw new Error(`expected an older page, got reset ${older.reset}`) + } + // The older page was read while the turn still ran. + expect(older.page.latestTurn?.turn.state).toBe('running') + const after = reduceStructuredAgentSession(ended, { + type: 'older-page', + requestedCursor: { epoch: ended.epoch ?? '', sequence: ended.items[0]?.sequence ?? 0 }, + page: older.page + }) + expect(runningStructuredAgentSessionTurnId(after)).toBeNull() + }) +}) + +describe('an older host, which publishes no latest turn', () => { + it('still reads the turn off the loaded rows', async () => { + await writeTurn('turn-1', { state: 'running', startedAt: 1_000 }) + const { latestTurn: _omitted, ...page } = readAgentSessionHydrationPage(journal, 1) + const state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { type: 'snapshot', sessionId: 'session-1', page, fence: 1 } + }) + + expect(state.latestTurn).toBeUndefined() + expect(runningStructuredAgentSessionTurnId(state)).toBe('turn-1') + }) + + it('reads a journal with no turn as idle, not unknown', async () => { + const state = hydrate() + + expect(state.latestTurn).toBeNull() + expect(runningStructuredAgentSessionTurnId(state)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 471f6fd3470c..8f4f8833a486 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -34,7 +34,6 @@ import { } from './native-chat-transcript-slots' import { useNativeChatTranscriptSlots } from './use-native-chat-transcript-slots' import { useNativeChatTranscriptWindow } from './use-native-chat-transcript-window' -import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' import { useNativeChatSubagentSections } from './use-native-chat-subagent-sections' import { toggleNativeChatExpandedKey } from './native-chat-expanded-keys' import { useNativeChatTurnMembership } from './use-native-chat-turn-membership' @@ -54,14 +53,11 @@ import type { AgentJournalRenderItem, AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' import { isStructuredAgentSessionThinking } from '../../../../shared/structured-agent-session-live-turn' import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' -import { - nativeChatTurnDiffs, - type NativeChatDiffReveal, - type NativeChatDiffTarget, - type NativeChatTurnDiff -} from './native-chat-turn-diffs' +import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' +import { useNativeChatTurnDiffs } from './use-native-chat-turn-diffs' /** The turn is blocked on the reader. `shown`: the pane draws the prompt itself, as a card; * `unshown`: it cannot (the prompt is only in the agent's terminal). */ @@ -75,6 +71,7 @@ export function NativeChatMessageList({ session, journalItems, journalSubmissions, + journalLatestTurn: latestTurn, subagentRoster, railOutline = null, isVisible = true, @@ -93,6 +90,8 @@ export function NativeChatMessageList({ journalItems?: readonly AgentJournalRenderItem[] /** With the items, what places each row in its turn (structured lane). */ journalSubmissions?: readonly AgentJournalSubmission[] + /** The host's newest turn record, which places a live turn whose record is not loaded. */ + journalLatestTurn?: AgentSessionLatestTurn | null /** Every subagent the session's rosters named, whether or not its roster row is loaded. */ subagentRoster?: Parameters[2] /** User messages older than the loaded window, from the host's outline. */ @@ -139,7 +138,8 @@ export function NativeChatMessageList({ const { messages, subagentRows } = useNativeChatTranscriptProjection( session, journalItems, - journalSubmissions + journalSubmissions, + latestTurn ) const { sections: subagentSections, @@ -151,23 +151,23 @@ export function NativeChatMessageList({ const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) // Each row's turn, which turn is live, and the order the rows draw in, resolved once. - const { - messages: rows, - turnKeys, - liveTurnKey - } = useNativeChatTurnMembership(messages, journalItems, journalSubmissions) - const turnDiffs = useMemo(() => { - if (!journalItems) { - return new Map() - } - const merged = nativeChatRowsInTranscriptOrder(rows, turnKeys, subagentRowsInOrder) - return nativeChatTurnDiffs(merged.messages, merged.turnKeys, subagentSections.pathOf) - }, [journalItems, rows, subagentRowsInOrder, subagentSections.pathOf, turnKeys]) + const turnRows = useNativeChatTurnMembership( + messages, + journalItems, + journalSubmissions, + latestTurn + ) + const { messages: rows, turnKeys, liveTurnKey } = turnRows + const turnDiffs = useNativeChatTurnDiffs( + journalItems ? turnRows : null, + subagentRowsInOrder, + subagentSections.pathOf + ) // "Thinking" is real reasoning content at the tail of the turn, not the absence // of output — the latter reports thinking while the request is merely in flight. const thinking = useMemo( - () => (journalItems ? isStructuredAgentSessionThinking(journalItems) : false), - [journalItems] + () => isStructuredAgentSessionThinking({ items: journalItems ?? [], latestTurn }), + [journalItems, latestTurn] ) const turnStatuses = useNativeChatTurnStatus({ turnKeys, diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx new file mode 100644 index 000000000000..641916ee314a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.unloaded-live-turn.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment happy-dom + +// A long turn's record and opening message sit above the loaded page. The host's newest turn record +// still names the turn, so its loaded rows draw under the live Working bar. + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const TURN_RECORD = 'turn-record-1' +const IN_TURN: AgentJournalTurnScope = { kind: 'turn', turnItemId: TURN_RECORD } + +/** The newest rows of the turn: everything from its 300th row on. */ +const loaded: AgentJournalRenderItem[] = [300, 301, 302].map((sequence) => ({ + itemId: `row-${sequence}`, + revision: 0, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `Step ${sequence}` }] + }, + turnScope: IN_TURN +})) + +const patch = '@@ -1 +1 @@\n-before\n+after' +/** An edit among the loaded rows; the turn's earlier edits are above them. */ +const edit: AgentJournalRenderItem = { + itemId: 'row-303', + revision: 0, + sequence: 303, + observedAt: 303, + body: { + kind: 'diff', + path: 'src/a.ts', + patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length } + }, + turnScope: IN_TURN +} + +const running: AgentSessionLatestTurn = { + itemId: TURN_RECORD, + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running', startedAt: 1_000, userItemId: 'user-1' } +} + +function list( + latestTurn: AgentSessionLatestTurn | null | undefined, + items: AgentJournalRenderItem[] = loaded +): React.JSX.Element { + return ( + + ) +} + +describe('a live turn whose record and opening message are not loaded', () => { + it('draws its Working bar with the running clock', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) + try { + render(list(running)) + expect(screen.getByText('Step 302')).toBeInTheDocument() + expect(screen.getByText(/Working for 1m 3s/)).toBeInTheDocument() + } finally { + now.mockRestore() + } + }) + + it('totals no edits, since only the end of the turn is loaded', () => { + render(list(running, [...loaded, edit])) + expect(screen.getByText(/Working for/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull() + }) + + it('had no bar to draw from the loaded rows alone', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(64_000) + try { + render(list(undefined)) + expect(screen.getByText('Step 302')).toBeInTheDocument() + expect(screen.queryByText(/Working for/)).not.toBeInTheDocument() + } finally { + now.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 7c8d6cf5d22b..b3102c5639bf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -286,6 +286,7 @@ export function NativeChatStructuredSession( session={session} journalItems={controller.journalItems} journalSubmissions={controller.submissions} + journalLatestTurn={controller.latestTurn} subagentRoster={controller.subagentRoster} railOutline={controller.railOutline} isVisible={props.isVisible} diff --git a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts index c03cd9a7f866..937d8eb29389 100644 --- a/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts +++ b/src/renderer/src/components/native-chat/native-chat-turn-diffs.ts @@ -30,12 +30,14 @@ export type NativeChatTurnDiff = { export function nativeChatTurnDiffs( messages: readonly NativeChatMessage[], turnKeys: readonly (string | undefined)[], - subagentSectionsOf?: ReadonlyMap + subagentSectionsOf?: ReadonlyMap, + /** A turn only partly loaded, whose totals would read as the whole turn's. */ + partialTurnKey?: string ): Map { const turns = new Map>() for (const [index, message] of messages.entries()) { const turnKey = turnKeys[index] - if (!turnKey) { + if (!turnKey || turnKey === partialTurnKey) { continue } const sections = subagentSectionsOf?.get(message.id) diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts index 5d7610faa6e9..b58446b222c8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-projection.ts @@ -5,6 +5,7 @@ import type { } from '../../../../shared/agent-session-journal-types' import type { NativeChatSubagentRow } from '../../../../shared/native-chat-transcript-projection' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows' @@ -15,7 +16,8 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session' export function useNativeChatTranscriptProjection( session: NativeChatLiveSession, journalItems: readonly AgentJournalRenderItem[] | undefined, - journalSubmissions: readonly AgentJournalSubmission[] | undefined + journalSubmissions: readonly AgentJournalSubmission[] | undefined, + latestTurn?: AgentSessionLatestTurn | null ): { messages: NativeChatMessage[] subagentRows: ReadonlyMap @@ -30,9 +32,11 @@ export function useNativeChatTranscriptProjection( () => projectMessages( session.messages, - journalItems ? { items: journalItems, submissions: journalSubmissions ?? [] } : null + journalItems + ? { items: journalItems, submissions: journalSubmissions ?? [], latestTurn } + : null ), - [journalItems, journalSubmissions, projectMessages, session.messages] + [journalItems, journalSubmissions, latestTurn, projectMessages, session.messages] ) const messages = useMemo(() => { const projected = projectNativeChatTaskListFrames(projection.conversation) diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts new file mode 100644 index 000000000000..598e2eaaad3e --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-diffs.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react' +import type { NativeChatSubagentRow } from '../../../../shared/native-chat-transcript-projection' +import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections' +import { nativeChatTurnDiffs, type NativeChatTurnDiff } from './native-chat-turn-diffs' +import type { NativeChatTurnRows } from './use-native-chat-turn-membership' + +const NO_TURN_DIFFS: ReadonlyMap = new Map() + +/** Each turn's recorded edit totals, a subagent's edits counted in the turn they happened. None + * without a journal (`turns` null), and none for a turn only partly loaded. */ +export function useNativeChatTurnDiffs( + turns: NativeChatTurnRows | null, + subagentRows: readonly NativeChatSubagentRow[], + subagentSectionsOf: ReadonlyMap +): ReadonlyMap { + return useMemo(() => { + if (!turns) { + return NO_TURN_DIFFS + } + const merged = nativeChatRowsInTranscriptOrder(turns.messages, turns.turnKeys, subagentRows) + return nativeChatTurnDiffs( + merged.messages, + merged.turnKeys, + subagentSectionsOf, + turns.partialTurnKey + ) + }, [subagentRows, subagentSectionsOf, turns]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts b/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts index 1793dcc1d68a..1077e288a5bc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-turn-membership.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' import { nativeChatTurnMembership } from '../../../../shared/native-chat-turn-membership' import { nativeChatRowsInDrawOrder } from '../../../../shared/native-chat-turn-grouping' @@ -13,6 +14,8 @@ export type NativeChatTurnRows = { /** Each drawn row's turn, by index into `messages`. */ turnKeys: readonly (string | undefined)[] liveTurnKey: string | undefined + /** The live turn, when only its tail is loaded (`NativeChatTurnMembership.partialTurnKey`). */ + partialTurnKey?: string } /** Each row's turn, which turn is live, and the order the rows draw in, resolved once: from the @@ -20,17 +23,21 @@ export type NativeChatTurnRows = { export function useNativeChatTurnMembership( messages: readonly NativeChatMessage[], journalItems: readonly AgentJournalRenderItem[] | undefined, - journalSubmissions: readonly AgentJournalSubmission[] | undefined + journalSubmissions: readonly AgentJournalSubmission[] | undefined, + latestTurn?: AgentSessionLatestTurn | null ): NativeChatTurnRows { return useMemo(() => { - const { turnKeys, liveTurnKey, drawOrder } = nativeChatTurnMembership( + const { turnKeys, liveTurnKey, drawOrder, partialTurnKey } = nativeChatTurnMembership( messages, - journalItems ? { items: journalItems, submissions: journalSubmissions ?? [] } : null + journalItems + ? { items: journalItems, submissions: journalSubmissions ?? [], latestTurn } + : null ) return { messages: nativeChatRowsInDrawOrder(messages, drawOrder), turnKeys: nativeChatRowsInDrawOrder(turnKeys, drawOrder), - liveTurnKey + liveTurnKey, + ...(partialTurnKey !== undefined ? { partialTurnKey } : {}) } - }, [journalItems, journalSubmissions, messages]) + }, [journalItems, journalSubmissions, latestTurn, messages]) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx index 7dbd9ca3610e..0b71b51cec93 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-stop.test.tsx @@ -11,6 +11,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' const mocks = vi.hoisted(() => ({ call: vi.fn(), @@ -21,6 +22,7 @@ let items: AgentJournalRenderItem[] = [] let submissions: AgentJournalSubmission[] = [] let outbox: StructuredAgentSessionOutboxEntry[] = [] let fence = 3 +let latestTurn: AgentSessionLatestTurn | null | undefined vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call, @@ -30,7 +32,15 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('./use-structured-agent-session-read', () => ({ useStructuredAgentSessionRead: () => ({ - state: { fence, items, submissions, status: 'ready', error: null, hasOlder: false }, + state: { + fence, + items, + submissions, + status: 'ready', + error: null, + hasOlder: false, + ...(latestTurn !== undefined ? { latestTurn } : {}) + }, loadingOlder: false, loadOlder: vi.fn() }) @@ -139,6 +149,7 @@ beforeEach(() => { submissions = [] outbox = [] fence = 3 + latestTurn = undefined }) describe('Stop against a host that stops the conversation', () => { @@ -542,3 +553,42 @@ describe.each([ expect(mocks.withdrawUnsent).not.toHaveBeenCalled() }) }) + +// A long turn's record sits above the loaded page; the host's record is what says it runs. +describe('a running turn whose record is not loaded', () => { + beforeEach(() => { + items = [] + latestTurn = { + itemId: 'turn-1', + observedAt: 1, + turn: { turnId: 'provider-turn', state: 'running', startedAt: 1 } + } + }) + + it('shows Working and Stop, and an older host gets the turn by name', async () => { + setLocalRuntimeCapabilitiesForTests([]) + const { result } = render() + + expect(result.current.isWorking).toBe(true) + expect(result.current.turnId).toBe('provider-turn') + expect(result.current.canStop).toBe(true) + await act(async () => { + await result.current.stop() + }) + expect(cancels()).toEqual([expect.objectContaining({ turnId: 'provider-turn' })]) + }) + + it('reads idle once the host ends it, though a running record is still loaded', () => { + items = [RUNNING_TURN] + latestTurn = { + itemId: 'turn-1', + observedAt: 2, + turn: { turnId: 'provider-turn', state: 'completed', startedAt: 1, completedAt: 2 } + } + setLocalRuntimeCapabilitiesForTests([]) + const { result } = render() + + expect(result.current.isWorking).toBe(false) + expect(result.current.canStop).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts index 3b235b4e8ebc..9e7a833ae1fa 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-transport-state.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' +import { runningStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-live-turn' import { isStructuredAgentSessionMainAgentWorking } from '../../../../shared/structured-agent-session-main-agent-working' import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' import type { StructuredAgentSubagentRoster } from '../../../../shared/structured-agent-session-subagent-roster' @@ -19,7 +19,9 @@ export function useStructuredAgentSessionTransportState( const submissions = enabled ? state.submissions : NO_SUBMISSIONS const subagentRoster = (enabled ? state.subagentRoster : undefined) ?? NO_SUBAGENT_ROSTER const fence = enabled ? state.fence : null - const turnId = activeStructuredAgentSessionTurnId(journalItems) + const latestTurn = enabled ? state.latestTurn : undefined + // The host's whole-journal turn, never the loaded rows': a long turn's record is off the page. + const turnId = runningStructuredAgentSessionTurnId({ items: journalItems, latestTurn }) // The rule the host projects every session list's Working from, so this chat cannot disagree. const isWorking = isStructuredAgentSessionMainAgentWorking(turnId, submissions, fence) const turnActivity = useMemo( @@ -30,12 +32,14 @@ export function useStructuredAgentSessionTransportState( { items: journalItems, submissions, + latestTurn, ...(enabled ? { hostClock: state.hostClock } : {}) }, turnId ) return { journalItems, + latestTurn, subagentRoster, submissions, fence, 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 ef5a3db46c18..dd7d06d6e5ac 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 @@ -233,6 +233,8 @@ export function useStructuredAgentSession(args: { ) }), journalItems: transcriptItems, + /** The host's newest turn record, which places a live turn whose record is not loaded. */ + latestTurn: transportState.latestTurn, subagentRoster: transportState.subagentRoster, messages, status: transportEnabled ? state.status : 'ready', diff --git a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts index e12975bf5ce1..b68a8a1c700c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-turn-timing.ts @@ -4,6 +4,7 @@ import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { NativeChatSettledTurns } from '../../../../shared/native-chat-turn-status' +import type { AgentSessionLatestTurn } from '../../../../shared/agent-session-wire' import type { StructuredAgentHostClock } from '../../../../shared/structured-agent-session-reducer' import { selectStructuredAgentTurnBars } from '../../../../shared/structured-agent-session-turn-timing' import { @@ -18,17 +19,19 @@ export function useStructuredAgentTurnTiming( { items, submissions, + latestTurn, hostClock }: { items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] + latestTurn?: AgentSessionLatestTurn | null hostClock?: StructuredAgentHostClock | null }, turnId: string | null ): { settledTurns: NativeChatSettledTurns; workingStartedAt: number | null } { const { settledTurns, runningTiming } = useMemo( - () => selectStructuredAgentTurnBars(items, submissions, turnId), - [items, submissions, turnId] + () => selectStructuredAgentTurnBars(items, submissions, turnId, latestTurn), + [items, submissions, turnId, latestTurn] ) const [latch, setLatch] = useState(null) // Stamp during render (React's derive-from-props pattern) so the first paint of diff --git a/src/shared/agent-session-turn-completion-wire.ts b/src/shared/agent-session-turn-completion-wire.ts new file mode 100644 index 000000000000..21c1b2ebb6ef --- /dev/null +++ b/src/shared/agent-session-turn-completion-wire.ts @@ -0,0 +1,49 @@ +import type { AgentJournalTurnOutcome } from './agent-session-journal-types' +import { agentSessionScopeKey, type AgentSessionExecutionLocation } from './agent-session-record' + +// Turn completion feed: the per-turn EDGE beside the status feed's STATE. + +/** + * The session's latest request reaching a terminal outcome — a root turn, or a send the agent or + * its start refused — derived by the EXECUTION HOST at journal commit. + * + * This is the EDGE, with turn identity; `AgentSessionStatusSummary.turnOutcome` is the STATE. + * The summary carries the verdict only while the session is idle, as a fact about the main agent's + * last turn that a status reader may act on (attention alerts, the `mainAgent.outcome` row field), + * and never a turn id: a reader that needs to know WHICH turn finished, or to react exactly once + * per finish, subscribes here. Re-broadcasting the summary on every status change therefore + * repeats a state, not a completion. + * + * `outcome` is the journal's recorded verdict (the provider's, a stop, or the host's supersede) and + * is never inferred — a turn the host only observed ending carries no outcome and produces no event + * at all, because absent means UNKNOWN, not success. + */ +export type AgentSessionTurnCompletion = { + /** Host-and-workspace scope; a bare provider turn id is not globally unique. */ + scope: AgentSessionExecutionLocation + sessionId: string + /** The request's identity: the root turn's id, or for a send refused before any turn, that + * send's journal item key. Neither is minted here. */ + turnId: string + outcome: AgentJournalTurnOutcome + /** Execution host's clock at journal commit. */ + completedAt: number + /** The request settled while a prompt waits on the user. Absent otherwise, and from older hosts. */ + awaitingUser?: true +} + +/** + * LIVE-ONLY: there is no snapshot arm and no replay arm, by decision. A subscriber is told what + * completes while it is subscribed and nothing else; completions that land while it is away are + * dropped rather than queued, so nothing durable can strand. On reconnect the client baselines. + */ +export type AgentSessionTurnCompletionEvent = + | { type: 'completion'; completion: AgentSessionTurnCompletion } + | { type: 'end' } + +/** Delivery dedupe address. Unread is idempotent and does not need it; mobile fanout does. */ +export function agentSessionTurnCompletionKey(completion: AgentSessionTurnCompletion): string { + return [agentSessionScopeKey(completion.scope), completion.sessionId, completion.turnId].join( + '\u0000' + ) +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index d6dd30dc481e..5cd462324362 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -12,6 +12,7 @@ import type { export * from './agent-session-wire-refusals' export * from './agent-session-queued-message-wire' +export * from './agent-session-turn-completion-wire' import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import type { AgentSessionContextUsage } from './agent-session-context-usage' // ─── Structured agent-session wire contract ───────────────────────────────── @@ -28,15 +29,10 @@ import type { AgentJournalResolution, AgentJournalSubmission, AgentJournalThreadGoal, - AgentJournalTurnOutcome + AgentJournalTurnLifecycle } from './agent-session-journal-types' import type { AgentTurnOutcome } from './agent-turn-outcome' -import { - agentSessionScopeKey, - type AgentSessionExecutionLocation, - type AgentSessionHandoffStage, - type AgentSessionRecord -} from './agent-session-record' +import type { AgentSessionHandoffStage, AgentSessionRecord } from './agent-session-record' import type { AgentProviderSessionMetadata } from './agent-session-resume' import type { NativeChatSubagentEntry } from './native-chat-types' import type { StructuredAgentSessionProjectedStatus } from './structured-agent-session-projection' @@ -64,6 +60,18 @@ export type AgentSessionTurnActivity = { text: string } +/** The session's newest turn record over the WHOLE journal. A page windows the timeline and a + * turn's record keeps the place it opened at, so a long turn's record falls off the page; this is + * what tells a client a turn is running. Present null: the journal records no turn. Absent: an + * older host, whose clients still read the loaded rows. */ +export type AgentSessionLatestTurn = { + /** The record's journal key, which rows of the turn name as their scope. */ + itemId: string + /** Host clock at the record's creation, as on its own row; a revision does not move it. */ + observedAt: number + turn: AgentJournalTurnLifecycle +} + export const AGENT_SESSION_ID_MAX_LENGTH = 512 /** Backward paging is the client's normal read; 40 matches the page size the @@ -133,6 +141,8 @@ export type AgentSessionHistoryPage = { /** Names the subagents with rows on the page whose roster row is older than it; bounded. * Absent from older hosts, and when every such roster row is on the page. */ subagentRoster?: AgentSessionSubagentRosterEntry[] + /** As of the page's read; a client applies it only from a page that replaces its state. */ + latestTurn?: AgentSessionLatestTurn | null } export type AgentSessionHistoryResult = @@ -192,6 +202,9 @@ export type AgentSessionSubscribeEvent = commands?: AgentSessionSlashCommand[] | null /** Additive ephemeral state; it never creates or advances journal rows. */ activity?: AgentSessionTurnActivity | null + /** Rides every batch that carries rows, removals or submissions, so absent there means an + * older host; absent on one that carries none, which changes no turn. */ + latestTurn?: AgentSessionLatestTurn | null } & AgentSessionHostClockField) | ({ type: 'reset' @@ -267,53 +280,6 @@ export type AgentSessionStatusEvent = | { type: 'status'; session: AgentSessionStatusSummary } | { type: 'end' } -// ─── Turn completion feed ─────────────────────────────────────────────────── - -/** - * The session's latest request reaching a terminal outcome — a root turn, or a send the agent or - * its start refused — derived by the EXECUTION HOST at journal commit. - * - * This is the EDGE, with turn identity; `AgentSessionStatusSummary.turnOutcome` is the STATE. - * The summary carries the verdict only while the session is idle, as a fact about the main agent's - * last turn that a status reader may act on (attention alerts, the `mainAgent.outcome` row field), - * and never a turn id: a reader that needs to know WHICH turn finished, or to react exactly once - * per finish, subscribes here. Re-broadcasting the summary on every status change therefore - * repeats a state, not a completion. - * - * `outcome` is the journal's recorded verdict (the provider's, a stop, or the host's supersede) and - * is never inferred — a turn the host only observed ending carries no outcome and produces no event - * at all, because absent means UNKNOWN, not success. - */ -export type AgentSessionTurnCompletion = { - /** Host-and-workspace scope; a bare provider turn id is not globally unique. */ - scope: AgentSessionExecutionLocation - sessionId: string - /** The request's identity: the root turn's id, or for a send refused before any turn, that - * send's journal item key. Neither is minted here. */ - turnId: string - outcome: AgentJournalTurnOutcome - /** Execution host's clock at journal commit. */ - completedAt: number - /** The request settled while a prompt waits on the user. Absent otherwise, and from older hosts. */ - awaitingUser?: true -} - -/** - * LIVE-ONLY: there is no snapshot arm and no replay arm, by decision. A subscriber is told what - * completes while it is subscribed and nothing else; completions that land while it is away are - * dropped rather than queued, so nothing durable can strand. On reconnect the client baselines. - */ -export type AgentSessionTurnCompletionEvent = - | { type: 'completion'; completion: AgentSessionTurnCompletion } - | { type: 'end' } - -/** Delivery dedupe address. Unread is idempotent and does not need it; mobile fanout does. */ -export function agentSessionTurnCompletionKey(completion: AgentSessionTurnCompletion): string { - return [agentSessionScopeKey(completion.scope), completion.sessionId, completion.turnId].join( - '\u0000' - ) -} - // ─── Mutation envelope ────────────────────────────────────────────────────── /** diff --git a/src/shared/native-chat-turn-membership.test.ts b/src/shared/native-chat-turn-membership.test.ts index 69e08b047938..79e48c8c87ea 100644 --- a/src/shared/native-chat-turn-membership.test.ts +++ b/src/shared/native-chat-turn-membership.test.ts @@ -200,6 +200,39 @@ describe('the live turn', () => { }) }) +describe('a turn whose record is above the loaded rows', () => { + const tail = () => [assistant('a300', inTurn('t1')), assistant('a301', inTurn('t1'))] + const hostTurn = (state: 'running' | 'completed', itemId = 't1') => ({ + itemId, + observedAt: 1, + turn: { turnId: itemId, state, userItemId: 'u1' } + }) + const membership = ( + items: readonly AgentJournalRenderItem[], + latestTurn: ReturnType + ) => nativeChatTurnMembership(rows(items), { items, submissions: [], latestTurn }) + + it('groups its tail under the live bar while it runs, marked partial', () => { + const live = membership(tail(), hostTurn('running')) + expect(live.turnKeys).toEqual(['t1', 't1']) + expect(live.liveTurnKey).toBe('t1') + expect(live.partialTurnKey).toBe('t1') + }) + + it('leaves its tail ungrouped once it ends, whichever turn is newest', () => { + expect(membership(tail(), hostTurn('completed')).turnKeys).toEqual([undefined, undefined]) + const next = membership([...tail(), user('u2')], hostTurn('running', 't2')) + expect(next.turnKeys.slice(0, 2)).toEqual([undefined, undefined]) + }) + + it('is whole once its record loads', () => { + const items = [user('u1'), turn('t1', 'u1', THREAD, 'running'), ...tail()] + const loaded = membership(items, hostTurn('running')) + expect(loaded.turnKeys).toEqual(['u1', 'u1', 'u1']) + expect(loaded.partialTurnKey).toBeUndefined() + }) +}) + // A message sent while A runs, which the provider queued behind A: its turn opens only after A's // remaining rows, which the journal wrote after it. describe('a message the provider answered after the running turn', () => { diff --git a/src/shared/native-chat-turn-membership.ts b/src/shared/native-chat-turn-membership.ts index b40290ae6002..20664521de2b 100644 --- a/src/shared/native-chat-turn-membership.ts +++ b/src/shared/native-chat-turn-membership.ts @@ -26,7 +26,11 @@ import { } from './native-chat-turn-grouping' import type { NativeChatRole } from './native-chat-types' import { isStructuredAgentSessionCommandTurn } from './structured-agent-session-command-entry' -import { liveStructuredAgentSessionTurnScope } from './structured-agent-session-live-turn' +import { + liveStructuredAgentSessionTurnScope, + runningStructuredAgentSessionTurnScope +} from './structured-agent-session-live-turn' +import type { AgentSessionLatestTurn } from './agent-session-wire' /** Whether the host writing this journal states each row's turn. Only a host that runs `/compact` * as a turn of the send path does, so this is also how a client tells that host from an older one. */ @@ -37,6 +41,10 @@ export function hostStatesTurnScopes(items: readonly AgentJournalRenderItem[]): export type NativeChatTurnJournal = { items: readonly AgentJournalRenderItem[] submissions: readonly AgentJournalSubmission[] + /** The host's newest turn record: it names the live turn, and anchors it while it runs when the + * record is not loaded, so the turn's loaded rows still draw under its bar. Absent from older + * hosts. */ + latestTurn?: AgentSessionLatestTurn | null } /** @@ -48,7 +56,9 @@ export type NativeChatTurnJournal = { */ export function structuredAgentTurnAnchors( items: readonly AgentJournalRenderItem[], - submissions: readonly AgentJournalSubmission[] = [] + submissions: readonly AgentJournalSubmission[] = [], + /** Anchored too while it runs with its record above the loaded rows; nothing loaded precedes it. */ + latestTurn?: AgentSessionLatestTurn | null ): ReadonlyMap { const userItemIds = new Set( items.flatMap((item) => @@ -89,6 +99,13 @@ export function structuredAgentTurnAnchors( ) inFlightSinceLastTurn = null } + // Only while running: an ended turn's tail would otherwise regroup when the next turn opens. + if (latestTurn?.turn.state === 'running' && !anchors.has(latestTurn.itemId)) { + anchors.set( + latestTurn.itemId, + anchorOf(latestTurn.itemId, latestTurn.turn, userItemIds, aliases, null, null) + ) + } return anchors } @@ -123,6 +140,9 @@ export type NativeChatTurnMembership = { /** Row indexes in the order the transcript draws them (`nativeChatTurnDrawOrder`), or null when * that is the order given. */ drawOrder: readonly number[] | null + /** The live turn's key while its record is above the loaded rows: those rows are only its tail, + * so nothing may total them as the whole turn. */ + partialTurnKey?: string } type NativeChatTurnMember = { id: string; role: NativeChatRole; unsent?: true } @@ -144,8 +164,8 @@ export function nativeChatTurnMembership( const turnKeys = withoutUnsent(messages, nativeChatRowTurnKeys(messages, null, opens)) return { turnKeys, liveTurnKey: newestUserTurnKey(messages, turnKeys), drawOrder: null } } - const anchors = structuredAgentTurnAnchors(journal.items, journal.submissions) - const running = liveStructuredAgentSessionTurnScope(journal.items) + const anchors = structuredAgentTurnAnchors(journal.items, journal.submissions, journal.latestTurn) + const running = runningStructuredAgentSessionTurnScope(journal) if (!hostStatesTurnScopes(journal.items)) { const recordKeys = namedRecordKeys(journal.items, anchors) const turnKeys = withoutUnsent( @@ -166,6 +186,7 @@ export function nativeChatTurnMembership( const runningKey = running.kind === 'turn' ? anchors.get(running.turnItemId) : undefined const anchoring = new Set(anchors.values()) const scopes = new Map(journal.items.map((item) => [item.itemId, item.turnScope])) + const runningRecordLoaded = running.kind === 'turn' && scopes.has(running.turnItemId) const turnKeys = messages.map((message) => { if (message.unsent === true) { return undefined @@ -181,7 +202,8 @@ export function nativeChatTurnMembership( return { turnKeys, liveTurnKey: runningKey ?? newestUserTurnKey(messages, turnKeys), - drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoring) + drawOrder: nativeChatTurnDrawOrder(messages, turnKeys, anchoring), + ...(runningKey !== undefined && !runningRecordLoaded ? { partialTurnKey: runningKey } : {}) } } diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 9d34b8db3825..761ea14197e1 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -125,4 +125,58 @@ describe('structured agent session event coalescer', () => { coalescer.flush() expect(events[1]).toMatchObject({ queuePause: { reason: 'stopped' } }) }) + + it('keeps the latest turn a coalesced frame carried, and a null one as an answer', () => { + const events: AgentSessionSubscribeEvent[] = [] + const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) + const running = { + itemId: 'turn-1', + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running' as const } + } + + coalescer.push({ ...batch(1), latestTurn: running }) + coalescer.push(batch(2)) + coalescer.flush() + coalescer.push({ ...batch(3), latestTurn: running }) + coalescer.push({ ...batch(4), latestTurn: null }) + coalescer.flush() + + expect(events.map((event) => (event.type === 'batch' ? event.latestTurn : 'other'))).toEqual([ + running, + null + ]) + }) + it('drops it when a later frame carries rows without it, as applying both would', () => { + const events: AgentSessionSubscribeEvent[] = [] + const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) + const token = (sequence: number) => ({ + ...batch(sequence), + batch: { + ...batch(sequence).batch, + items: [ + { + itemId: `token-${sequence}`, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message' as const, role: 'assistant' as const, blocks: [] } + } + ] + } + }) + const running = { + itemId: 'turn-1', + observedAt: 1, + turn: { turnId: 'turn-1', state: 'running' as const } + } + + // The second frame is an older host's: rows, and no answer to keep the first one alive. + coalescer.push({ ...token(1), latestTurn: running }) + coalescer.push(token(2)) + coalescer.flush() + + expect(events).toHaveLength(1) + expect(events[0]).not.toHaveProperty('latestTurn') + }) }) diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index 8564056ce048..94442408eb39 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -1,4 +1,5 @@ import type { AgentSessionSubscribeEvent } from './agent-session-wire' +import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48 @@ -23,6 +24,8 @@ function mergeBatch( for (const submission of right.batch.submissions) { submissions.set(submission.clientMessageId, submission) } + // As applying both in turn would leave it, so an older host's rows still drop a stale claim. + const latestTurn = latestTurnAfterStructuredAgentSessionBatch(left.latestTurn, right) return { type: 'batch', ...(right.commands !== undefined || left.commands !== undefined @@ -55,7 +58,8 @@ function mergeBatch( : {}), ...(right.activity !== undefined || left.activity !== undefined ? { activity: right.activity !== undefined ? right.activity : (left.activity ?? null) } - : {}) + : {}), + ...(latestTurn !== undefined ? { latestTurn } : {}) } } diff --git a/src/shared/structured-agent-session-item-retention.ts b/src/shared/structured-agent-session-item-retention.ts index c634a98c0ce1..fef4d5e54688 100644 --- a/src/shared/structured-agent-session-item-retention.ts +++ b/src/shared/structured-agent-session-item-retention.ts @@ -1,6 +1,7 @@ // How much of a session's journal a client keeps in memory. -import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' // Well above the renderer's initial read window (300) plus a page, so only genuinely @@ -37,3 +38,28 @@ export function trimRetainedItems( } return start === 0 ? items : items.slice(start) } + +const MAX_RETAINED_SUBMISSIONS = 256 + +export function mergeSubmissions( + current: readonly AgentJournalSubmission[], + incoming: readonly AgentJournalSubmission[], + items: readonly AgentJournalRenderItem[] +): AgentJournalSubmission[] { + const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) + for (const submission of incoming) { + byId.set(submission.clientMessageId, submission) + } + const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) + const itemIds = new Set( + items + .filter((item) => item.body.kind === 'message' && item.body.role === 'user') + .map((item) => item.itemId) + ) + // Loaded user messages need their provider alias for durable turn attribution. + return sorted.filter( + (submission, index) => + index >= sorted.length - MAX_RETAINED_SUBMISSIONS || + itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ) +} diff --git a/src/shared/structured-agent-session-live-turn.test.ts b/src/shared/structured-agent-session-live-turn.test.ts index ba48ab3b4812..5a4d1f7d43c8 100644 --- a/src/shared/structured-agent-session-live-turn.test.ts +++ b/src/shared/structured-agent-session-live-turn.test.ts @@ -27,7 +27,7 @@ describe('isStructuredAgentSessionThinking', () => { }) it('is true while reasoning is the newest thing the turn produced', () => { - expect(isStructuredAgentSessionThinking([turnStart, reasoning(2)])).toBe(true) + expect(isStructuredAgentSessionThinking({ items: [turnStart, reasoning(2)] })).toBe(true) }) it("reads a reasoning row's own state when its host keeps one", () => { @@ -46,7 +46,7 @@ describe('isStructuredAgentSessionThinking', () => { it('is false once a tool call, a message or a diff lands after the reasoning', () => { const after = (body: AgentJournalRenderItem['body']): boolean => - isStructuredAgentSessionThinking([turnStart, reasoning(2), item('after', 3, body)]) + isStructuredAgentSessionThinking({ items: [turnStart, reasoning(2), item('after', 3, body)] }) expect(after({ kind: 'tool-call', name: 'shell', input: null, state: 'running' })).toBe(false) expect( after({ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'Here you go' }] }) @@ -61,8 +61,8 @@ describe('isStructuredAgentSessionThinking', () => { }) it('is false when a turn produced no reasoning at all', () => { - expect(isStructuredAgentSessionThinking([turnStart])).toBe(false) - expect(isStructuredAgentSessionThinking([])).toBe(false) + expect(isStructuredAgentSessionThinking({ items: [turnStart] })).toBe(false) + expect(isStructuredAgentSessionThinking({ items: [] })).toBe(false) }) it('does not read an earlier turn as this one reasoning', () => { @@ -73,7 +73,7 @@ describe('isStructuredAgentSessionThinking', () => { text: 'Working', turnLifecycle: { turnId: 'turn-2', state: 'running' } }) - expect(isStructuredAgentSessionThinking([reasoning(1), newTurn])).toBe(false) + expect(isStructuredAgentSessionThinking({ items: [reasoning(1), newTurn] })).toBe(false) }) it('does not read a completed turn as reasoning during the next pending dispatch', () => { @@ -82,20 +82,47 @@ describe('isStructuredAgentSessionThinking', () => { turnId: 'turn-1', state: 'completed' }) - expect(isStructuredAgentSessionThinking([completedTurn, reasoning(2)])).toBe(false) + expect(isStructuredAgentSessionThinking({ items: [completedTurn, reasoning(2)] })).toBe(false) }) it('stops at a typed turn item, the carrier this host writes', () => { const typedTurn = (sequence: number, turnId: string): AgentJournalRenderItem => item(`turn-${turnId}`, sequence, { kind: 'turn', turnId, state: 'running' }) - expect(isStructuredAgentSessionThinking([typedTurn(1, 'turn-1'), reasoning(2)])).toBe(true) - expect(isStructuredAgentSessionThinking([reasoning(1), typedTurn(2, 'turn-2')])).toBe(false) + expect( + isStructuredAgentSessionThinking({ items: [typedTurn(1, 'turn-1'), reasoning(2)] }) + ).toBe(true) + expect( + isStructuredAgentSessionThinking({ items: [reasoning(1), typedTurn(2, 'turn-2')] }) + ).toBe(false) + }) + + it("reads the host's running turn when the turn's record is above the loaded rows", () => { + const hostTurn = (state: 'running' | 'completed') => ({ + itemId: 'turn-start', + observedAt: 1, + turn: { turnId: 'turn-1', state } + }) + expect( + isStructuredAgentSessionThinking({ items: [reasoning(2)], latestTurn: hostTurn('running') }) + ).toBe(true) + expect( + isStructuredAgentSessionThinking({ items: [reasoning(2)], latestTurn: hostTurn('completed') }) + ).toBe(false) + // An older host states nothing, and the loaded rows alone name no turn. + expect(isStructuredAgentSessionThinking({ items: [reasoning(2)] })).toBe(false) + // The host's answer outranks a loaded record whose ending revision is off the window. + expect( + isStructuredAgentSessionThinking({ + items: [turnStart, reasoning(2)], + latestTurn: hostTurn('completed') + }) + ).toBe(false) }) it('lets an unmarked status stay transparent to the latest reasoning state', () => { const plan = item('plan', 3, { kind: 'status', text: 'Step 1. Read the file' }) - expect(isStructuredAgentSessionThinking([turnStart, plan])).toBe(false) - expect(isStructuredAgentSessionThinking([turnStart, reasoning(2), plan])).toBe(true) + expect(isStructuredAgentSessionThinking({ items: [turnStart, plan] })).toBe(false) + expect(isStructuredAgentSessionThinking({ items: [turnStart, reasoning(2), plan] })).toBe(true) }) it.each([ @@ -124,7 +151,9 @@ describe('isStructuredAgentSessionThinking', () => { } ])('stops thinking when the turn is waiting on a $kind', (prompt) => { expect( - isStructuredAgentSessionThinking([turnStart, reasoning(2), item('prompt', 3, prompt)]) + isStructuredAgentSessionThinking({ + items: [turnStart, reasoning(2), item('prompt', 3, prompt)] + }) ).toBe(false) }) }) @@ -154,7 +183,9 @@ describe("the live-turn readers answer for the session's own agent", () => { role: 'reasoning', blocks: [{ type: 'text', text: 'Weighing two approaches' }] }) - expect(isStructuredAgentSessionThinking([turnStart, spawnCall, childReasoning])).toBe(false) + expect( + isStructuredAgentSessionThinking({ items: [turnStart, spawnCall, childReasoning] }) + ).toBe(false) }) it('still reports the parent as thinking when the parent itself is reasoning', () => { @@ -163,7 +194,9 @@ describe("the live-turn readers answer for the session's own agent", () => { role: 'reasoning', blocks: [{ type: 'text', text: 'Weighing two approaches' }] }) - expect(isStructuredAgentSessionThinking([turnStart, spawnCall, ownReasoning])).toBe(true) + expect(isStructuredAgentSessionThinking({ items: [turnStart, spawnCall, ownReasoning] })).toBe( + true + ) }) it("reports the parent's own running call while a subagent runs its own", () => { diff --git a/src/shared/structured-agent-session-live-turn.ts b/src/shared/structured-agent-session-live-turn.ts index f630d39ebe65..b7ce267fb427 100644 --- a/src/shared/structured-agent-session-live-turn.ts +++ b/src/shared/structured-agent-session-live-turn.ts @@ -24,6 +24,7 @@ import { type AgentJournalTurnScope } from './agent-session-journal-types' import { isRootAgentJournalItem } from './agent-session-journal-producer' +import type { AgentSessionLatestTurn, AgentSessionSubscribeEvent } from './agent-session-wire' import { readAgentJournalTurn } from './agent-session-turn-record' import type { NativeChatToolCallBlock } from './native-chat-types' import { @@ -101,15 +102,61 @@ export function activeStructuredAgentSessionTurnIdBySequence( export function newestStructuredAgentSessionTurn( items: readonly AgentJournalRenderItem[] ): AgentJournalTurnLifecycle | null { + return latestStructuredAgentSessionTurn(items)?.turn ?? null +} + +/** The same record as a page publishes it, with the identity a client keys the turn by. */ +export function latestStructuredAgentSessionTurn( + items: readonly AgentJournalRenderItem[] +): AgentSessionLatestTurn | null { for (let index = items.length - 1; index >= 0; index -= 1) { - const turn = readAgentJournalTurn(items[index]?.body) - if (turn) { - return turn + const item = items[index] + const turn = readAgentJournalTurn(item?.body) + if (item && turn) { + return { itemId: item.itemId, observedAt: item.observedAt, turn } } } return null } +/** The turn the session's own agent is running, for a client: the host's answer over the whole + * journal, which no loaded window can hide. Temporary: an older host sends none, so its clients + * still read the loaded rows' newest record; delete that arm once such hosts age out. */ +export function runningStructuredAgentSessionTurnId(state: HostTurnSource): string | null { + if (state.latestTurn === undefined) { + return activeStructuredAgentSessionTurnId(state.items) + } + return state.latestTurn?.turn.state === 'running' ? state.latestTurn.turn.turnId : null +} + +/** The scope a row of that running turn names, read the same way. */ +export function runningStructuredAgentSessionTurnScope( + state: HostTurnSource +): AgentJournalTurnScope { + if (state.latestTurn === undefined) { + return liveStructuredAgentSessionTurnScope(state.items) + } + return state.latestTurn?.turn.state === 'running' + ? { kind: 'turn', turnItemId: state.latestTurn.itemId } + : AGENT_JOURNAL_THREAD_SCOPE +} + +type HostTurnSource = { + items: readonly AgentJournalRenderItem[] + latestTurn?: AgentSessionLatestTurn | null +} + +/** The host's answer once `event` applies. A batch carrying rows restates it, so one without it + * came from an older host and falls back to the rows rather than keep a claim nothing renews. */ +export function latestTurnAfterStructuredAgentSessionBatch( + previous: AgentSessionLatestTurn | null | undefined, + event: Extract +): AgentSessionLatestTurn | null | undefined { + const { items, removedItemIds, submissions } = event.batch + const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0 + return carriesRows || event.latestTurn !== undefined ? event.latestTurn : previous +} + /** * Whether the newest thing the active turn produced is the model's own reasoning. * @@ -118,16 +165,16 @@ export function newestStructuredAgentSessionTurn( * thinking while the request is merely in flight, and stops reporting it the moment a tool call * lands, which is usually when reasoning actually starts. */ -export function isStructuredAgentSessionThinking( - items: readonly AgentJournalRenderItem[] -): boolean { +export function isStructuredAgentSessionThinking({ items, latestTurn }: HostTurnSource): boolean { + // The host's answer outranks a loaded record, whose newest revision may be off the window. + const hostRunning = latestTurn === undefined ? null : latestTurn?.turn.state === 'running' let newestContentIsReasoning: boolean | null = null for (let index = items.length - 1; index >= 0; index -= 1) { const item = items[index] const body = item?.body const turn = readAgentJournalTurn(body) if (turn) { - return turn.state === 'running' && newestContentIsReasoning === true + return (hostRunning ?? turn.state === 'running') && newestContentIsReasoning === true } if (newestContentIsReasoning !== null || !isRootAgentJournalItem(item)) { continue @@ -146,7 +193,8 @@ export function isStructuredAgentSessionThinking( } // A status row is a notice, not newer transcript content. } - return false + // The record is above the loaded rows, so every loaded root row is newer than it. + return hostRunning === true && newestContentIsReasoning === true } /** The tool the status row names for the SESSION'S OWN agent, as the chat draws it: the running diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts index 8a9ded2170da..1592e949b1b2 100644 --- a/src/shared/structured-agent-session-reducer.test.ts +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' -import type { AgentSessionHistoryPage } from './agent-session-wire' +import type { AgentSessionHistoryPage, AgentSessionLatestTurn } from './agent-session-wire' +import { runningStructuredAgentSessionTurnId } from './structured-agent-session-live-turn' import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession @@ -534,6 +535,66 @@ describe('structured agent session reducer', () => { expect(cleared.items).toBe(active.items) }) + describe("the host's newest turn record", () => { + const turnRow = (state: 'running' | 'completed'): AgentJournalRenderItem => ({ + itemId: 'turn-record', + revision: state === 'running' ? 1 : 2, + sequence: 1, + observedAt: 1, + body: { kind: 'turn', turnId: 'turn-1', state } + }) + const hostTurn = (state: 'running' | 'completed'): AgentSessionLatestTurn => ({ + itemId: 'turn-record', + observedAt: 1, + turn: { turnId: 'turn-1', state } + }) + const opened = () => + reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: { ...hydrationPage([turnRow('running')]), latestTurn: hostTurn('running') } + } + }) + const batch = ( + state: ReturnType, + items: AgentJournalRenderItem[], + latestTurn?: AgentSessionLatestTurn | null + ) => + reduceStructuredAgentSession(state, { + type: 'event', + event: { + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: state.cursor!.sequence + items.length }, + items, + removedItemIds: [], + submissions: [] + }, + activity: { turnId: 'turn-1', text: 'Reading' }, + ...(latestTurn !== undefined ? { latestTurn } : {}) + } + }) + + it('keeps it across a frame that carries no rows, and takes the next one rows carry', () => { + const quiet = batch(opened(), []) + expect(quiet.latestTurn).toEqual(hostTurn('running')) + const ended = batch(quiet, [turnRow('completed')], hostTurn('completed')) + expect(ended.latestTurn).toEqual(hostTurn('completed')) + expect(runningStructuredAgentSessionTurnId(ended)).toBeNull() + }) + + it('falls back to the loaded rows when rows arrive without it, as from an older host', () => { + // A cursor resume against a downgraded host: its rows end the turn but restate nothing. + const downgraded = batch(opened(), [turnRow('completed')]) + expect(downgraded.latestTurn).toBeUndefined() + expect(runningStructuredAgentSessionTurnId(downgraded)).toBeNull() + }) + }) + it('records the host clock from frames that carry it and keeps it otherwise', () => { const snapshot = reduceStructuredAgentSession( EMPTY_STRUCTURED_AGENT_SESSION, diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index f19a0fc4e1cc..e8c78166a2ea 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -7,6 +7,7 @@ import type { AgentSessionBackgroundTaskState, AgentSessionSlashCommand, AgentSessionHistoryPage, + AgentSessionLatestTurn, AgentSessionQueuedMessage, AgentSessionQueuePause, AgentSessionSubscribeEvent, @@ -15,15 +16,16 @@ import type { import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality' import { admitAgentSessionBackgroundTaskState } from './agent-session-background-task-state-admission' -import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { MAX_RETAINED_ITEMS, MAX_RETAINED_OWN_ITEMS, + mergeSubmissions, ownItemCount, trimRetainedItems } from './structured-agent-session-item-retention' import { compareAgentJournalItems } from './agent-session-journal-position' import { readAgentJournalTurn } from './agent-session-turn-record' +import { latestTurnAfterStructuredAgentSessionBatch } from './structured-agent-session-live-turn' import { foldStructuredAgentSubagentRoster, foldStructuredAgentSubagentRosterPage, @@ -67,6 +69,9 @@ export type StructuredAgentSessionState = { /** Every subagent a roster row this client received named, by agent id; not trimmed with * `items`. Absent until a page has been applied. */ subagentRoster?: StructuredAgentSubagentRoster + /** The host's newest turn record over the whole journal, which says whether a turn runs; absent + * from an older host, whose answer is read off `items` instead. */ + latestTurn?: AgentSessionLatestTurn | null /** Bumped per live batch that leaves a turn row's newest revision outside the window * (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */ unloadedTurnRevisions?: number @@ -81,8 +86,6 @@ export type StructuredAgentSessionAction = | { type: 'history-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage } -const MAX_RETAINED_SUBMISSIONS = 256 - export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { epoch: null, cursor: null, @@ -136,6 +139,7 @@ function replacePage( status: 'ready', subagentRoster: foldStructuredAgentSubagentRosterPage(undefined, page), activity: activity ?? null, + ...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}), ...(backgroundTasks !== undefined ? { backgroundTasks } : page.backgroundTasks !== undefined @@ -182,29 +186,6 @@ function liveItemsWithinWindow( return incoming.filter((item) => item.sequence >= head.sequence) } -function mergeSubmissions( - current: readonly AgentJournalSubmission[], - incoming: readonly AgentJournalSubmission[], - items: readonly AgentJournalRenderItem[] -): AgentJournalSubmission[] { - const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) - for (const submission of incoming) { - byId.set(submission.clientMessageId, submission) - } - const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt) - const itemIds = new Set( - items - .filter((item) => item.body.kind === 'message' && item.body.role === 'user') - .map((item) => item.itemId) - ) - // Loaded user messages need their provider alias for durable turn attribution. - return sorted.filter( - (submission, index) => - index >= sorted.length - MAX_RETAINED_SUBMISSIONS || - itemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) - ) -} - /** `receivedAt` is the client clock at apply time; callers pass it so the reducer stays pure. */ export function reduceStructuredAgentSession( state: StructuredAgentSessionState, @@ -336,6 +317,7 @@ export function reduceStructuredAgentSession( error: undefined, readRefusal: undefined, commands: event.commands !== undefined ? event.commands : state.commands, + latestTurn: latestTurnAfterStructuredAgentSessionBatch(state.latestTurn, event), ...queuePublicationField(event, state), backgroundTasks, ...(activity !== undefined ? { activity } : {}), diff --git a/src/shared/structured-agent-session-turn-timing.ts b/src/shared/structured-agent-session-turn-timing.ts index d5173ae44aea..0ca1560d9811 100644 --- a/src/shared/structured-agent-session-turn-timing.ts +++ b/src/shared/structured-agent-session-turn-timing.ts @@ -13,6 +13,7 @@ import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-sessi import { agentTurnVerdict, type AgentTurnOutcome } from './agent-turn-outcome' import { structuredAgentTurnAnchors } from './native-chat-turn-membership' import type { NativeChatSettledTurn, NativeChatSettledTurns } from './native-chat-turn-status' +import type { AgentSessionLatestTurn } from './agent-session-wire' export type StructuredAgentTurnTiming = { state: AgentJournalTurnLifecycleState @@ -36,7 +37,7 @@ export type StructuredAgentTurnTiming = { } function readTiming( - item: AgentJournalRenderItem, + item: Pick, precedingTurnEndedAt: number | undefined ): StructuredAgentTurnTiming | null { const turn = readAgentJournalTurn(item.body) @@ -220,14 +221,27 @@ function settledTurnsOf( export function selectStructuredAgentTurnBars( items: readonly AgentJournalRenderItem[], submissions: readonly AgentJournalSubmission[], - turnId: string | null + turnId: string | null, + /** The host's newest turn record, which times a running turn whose row is not loaded. */ + latestTurn?: AgentSessionLatestTurn | null ): { settledTurns: NativeChatSettledTurns runningTiming: StructuredAgentTurnTiming | null } { const turns = readStructuredAgentJournalTurns(items, submissions) + const unloaded = + turnId !== null && !turns.byTurnId.has(turnId) && latestTurn?.turn.turnId === turnId + ? latestTurn + : null return { settledTurns: settledTurnsOf(turns, submissions), - runningTiming: turnId === null ? null : (turns.byTurnId.get(turnId) ?? null) + runningTiming: unloaded + ? readTiming( + { body: { kind: 'turn', ...unloaded.turn }, observedAt: unloaded.observedAt }, + undefined + ) + : turnId === null + ? null + : (turns.byTurnId.get(turnId) ?? null) } } From 468e4e1167b4bd64d510022f29827610a37aed30 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:57:58 -0700 Subject: [PATCH 06/71] fix(native-chat): a prompt card owns the chat input until its answer lands (terminal-backed chat, desktop and phone) (#25761) * fix(native-chat): an answerable prompt card owns the chat input until its answer lands * fix(mobile): a terminal chat's composer waits while its prompt card is up * test(native-chat): type the prompt card fixtures without casts * fix(native-chat): scope replies to acknowledged prompt occurrences * fix(native-chat): preserve answer ordering and verified delivery * test(native-chat): keep mock RPC client inside test boundary * test(native-chat): place mock fixtures in the test-only scope * Keep runtime comments within the module size limit * test: preserve prompt delivery coverage in desktop CI * Treat an older host's accepted write as delivered A newer desktop or phone talking to a host that predates the write settlement field read every accepted reply as "unconfirmed". Prompt cards never dismissed, the phone showed "Response unconfirmed" on every tap and ordinary chat messages were held as "Delivery unconfirmed". The reader now uses writeSettlement when present and otherwise keeps the host's whole-write accepted/refused verdict, exactly as before this branch. Only prompt answers ask for provider settlement; ordinary callers (follow-up delivery, paste drafts, option commands, composer sends) are back on the original contract, so the legacy-handoff error class, its flag, the sequence-only send helper and the mobile handoff hook are gone. * Keep terminal-pane Escape on the plain accepted write Every pane's Escape/Ctrl+C goes through pty:writeAccepted. This branch had switched that IPC to wait for provider settlement, which dropped the "remount this pane" signal for a daemon session awaiting recovery and could stall later keystrokes behind a slow daemon acknowledgment. pty:writeAccepted is back to its original local-only, synchronous write. Prompt answers opt into settlement with requireWriteSettlement on the same channel, and a settled refusal while the daemon recovers now sends the same remount signal. Ordinary verified sends regain their original fallback write. * Report a partly accepted local paste as unconfirmed A settled local write split into chunks returned plain false when a later chunk was refused after earlier ones were accepted. Callers read false as "nothing was written", so chat showed "Message not sent" with a prefix already in the agent's input. It now reports the write as unconfirmed, the same verdict the paired host gives for a partial write. * Hide the chat composer under a prompt card instead of unmounting it When an approval or question card took the input region, the composer unmounted. A message still waiting for its Enter was cancelled and its bubble deleted after the draft had already been cleared, so the message vanished without a notice; composer history was also wiped each time. The composer now stays mounted but hidden while a card owns input, so its state survives. A send that has not submitted yet is still stopped (its Enter would answer the card), but its bubble stays with "Message not sent" so the text is not lost. The composer ref is detached while hidden, so root typing, paste and reveal focus never reach it. * Keep an answered prompt hidden after the chat view remounts The "answered" dismissal lived in component state. Toggling chat to terminal and back, a PTY reconnect, or leaving the phone session and coming back while the approved tool was still running brought the answered approval back, and it then took over the input again. Desktop now keeps the answered occurrence per pane outside the view; phone keeps it per chat tab outside the controller. Both still retire it when the pane observes the prompt clear or change, desktop also when the tab retires, and both maps are size-bounded. * Update the prompt-reply reliability gate for the review fixes Older hosts' accepted answers now dismiss like acknowledged ones, the composer stays mounted under a card, and answered prompts survive a view remount. The gate's invariant, oracle, assertion list, new test files and the two latest evidence runs now describe that contract. * Let users hide a prompt card, keep Escape from denying, and gate only Send on the phone The chat input could stay locked behind a card the host never closes (for example after a Deny typed in the terminal), and Escape on a focused approval card denied the tool even when the user meant to close a picker. - A Hide control (chevron) on terminal approval and question cards, desktop and phone, hides that prompt occurrence and gives the input back. It writes nothing to the agent and uses the same per-occurrence dismissal as an acknowledged answer, so a new occurrence shows the card again. - On desktop, Escape on a card now does the same Hide instead of Deny, and a card that appears while the user is typing no longer takes focus. - On the phone, a card blocks only Send: typing, dictation and image attach keep working on the draft. The placeholder is back to the normal one. * Fix two comments that still called older-host replies unconfirmed Since an older host's accepted write now counts as delivered, the requireWriteSettlement comment and the reliability gate's oracle said the opposite of the code. Both now describe the current rule. * Collapse prompt cards to a strip instead of hiding them, and close the round-2 gaps Hide removed a card completely, so nothing on screen said a prompt was still waiting, and several edges let the chat type into a live prompt. - Collapse (the header chevron, or Escape on desktop) folds the card to a one-line strip above the composer; the strip's chevron expands it back. Collapsing writes nothing, frees the composer, and is disabled while an answer is still being written. Each pane or tab keeps the occurrence as answered or collapsed, so a remount restores the same view. - Questions now carry the host wait's start like approvals, so an identical question in a new wait shows again (desktop and phone). A transcript-only prompt, which has no wait start, is dropped when the view stops observing it, and a transcript still loading no longer clears a dismissal. - Desktop: while a card owns the input, the hidden composer cannot send or interrupt even if it still has keyboard focus, and the card takes focus in the same commit. A send the card retires no longer types Ctrl+U under it. - Phone: an Ask hides the heuristic card read from the same waiting status, and the dismissal store is scoped by host, worktree and tab. * Keep a collapsed card's partial answer, and scope its focus to its own pane Collapsing a question card unmounted it, so expanding it again lost the chosen step, selections and typed "Other" text; Escape typed in that text field collapsed the card. A card arriving while the user typed in another surface (sidebar, notes, a browser URL bar) also took the keyboard. - The collapsed card now stays mounted but hidden (and inert on desktop) under its strip, on desktop and phone, so a partial answer survives collapse and expand. Escape inside the card's text field no longer collapses it. The question card shows the same focus ring as the approval card. - A card takes focus only from inside its own pane (its hidden composer) or from the page body, never from a text field elsewhere. - Desktop and phone share one dismissal store in src/shared, bounded by the existing scope-cache helper, which moves to src/shared with it. - The card send imports the verified helper from its own module, and the phone files are split so each name matches its contents (header action, strip, lane selector). * Return focus to the composer after a prompt card collapses Since a collapsed card stays mounted, Escape or the chevron left keyboard focus inside the now hidden, inert card. The composer's reveal-focus took that as focus already in the pane and stood down, then the browser dropped focus to the page body, so typed keys went nowhere. Reveal-focus now treats focus inside a hidden or inert subtree as not in the pane and focuses the composer. On the phone, collapsing a card also dismisses the keyboard so a hidden reply field does not keep it. * Keep the question card's collapse chevron beside its Cancel button The question card header spread its three items with justify-between, which put the new chevron in the middle of the header. The title now takes the free space, as in the approval card, so the chevron sits next to Cancel at the right edge. * Run the prompt tests on the merged main Main now runs Vitest under Bun, which resolves a long data: URL import as a package name, so the SSH delivery test loads its bundled mobile module from a temp file instead. The phone prompt harnesses mock the live line that main's view now renders, and add Platform, which main's text-selection helper reads, the same way main's own view tests do. --- config/reliability-gates.jsonc | 227 ++++++++++- config/scripts/ci-shard-timings.json | 2 +- mobile/src/session/MobileNativeChatAsk.tsx | 17 +- .../MobileNativeChatCardHeaderAction.test.tsx | 92 +++++ .../MobileNativeChatCardHeaderAction.tsx | 45 +++ .../src/session/MobileNativeChatComposer.tsx | 6 +- .../src/session/MobileNativeChatOverlay.tsx | 4 + .../MobileNativeChatPermission.test.ts | 7 +- .../session/MobileNativeChatPermission.tsx | 31 +- .../MobileNativeChatPromptCard.test.tsx | 54 +++ .../session/MobileNativeChatPromptCard.tsx | 107 +++--- .../session/MobileNativeChatPromptStrip.tsx | 45 +++ .../session/MobileNativeChatQuestion.test.tsx | 2 + .../src/session/MobileNativeChatQuestion.tsx | 31 +- ...NativeChatView.prompt-presentation.test.ts | 229 +++++++++++ .../src/session/MobileNativeChatView.test.ts | 32 ++ mobile/src/session/MobileNativeChatView.tsx | 40 +- .../__mocks__/mobile-prompt-controller.ts | 299 +++++++++++++++ .../mobile-native-chat-controller-contract.ts | 7 + ...mobile-native-chat-permission-send.test.ts | 1 + .../mobile-native-chat-permission-send.ts | 1 + .../mobile-native-chat-prompt-dismissals.ts | 12 + mobile/src/session/mobile-native-chat-send.ts | 14 + .../session/mobile-prompt-controller.test.ts | 359 ++++++++++++++++++ .../use-mobile-native-chat-answer-send.ts | 7 +- ...use-mobile-native-chat-ask-dismiss.test.ts | 2 + .../use-mobile-native-chat-ask-dismiss.ts | 89 ++--- .../use-mobile-native-chat-cancel-ask.ts | 3 +- .../use-mobile-native-chat-controller.test.ts | 8 +- .../use-mobile-native-chat-controller.ts | 54 +-- .../use-mobile-native-chat-input-lease.ts | 11 + .../use-mobile-native-chat-message-send.ts | 7 +- .../use-mobile-native-chat-prompt-cards.ts | 53 +++ .../use-mobile-native-chat-prompt-dismiss.ts | 78 ++++ ...-mobile-native-chat-prompt-presentation.ts | 78 ++++ .../session/use-mobile-native-chat-prompts.ts | 5 +- .../pty/ipc/write-input-acceptance.test.ts | 128 +++++++ src/main/ipc/pty/ipc/write-input.ts | 108 +++++- ...ca-runtime-controller-knows-pty-is-live.ts | 26 +- ...-authoritative-terminal-wait-permission.ts | 5 +- src/main/runtime/orca-runtime-runtime-id.ts | 15 +- .../methods/terminal/terminal-send-method.ts | 1 + .../runtime/runtime-terminal-writer.test.ts | 162 ++++++++ src/main/runtime/runtime-terminal-writer.ts | 94 ++++- src/preload/api/pty-api.ts | 8 +- src/preload/api/pty-bridge-session-control.ts | 9 +- .../NativeChatApprovalCard.test.tsx | 24 ++ .../native-chat/NativeChatApprovalCard.tsx | 29 +- .../native-chat/NativeChatComposer.test.tsx | 17 + .../native-chat/NativeChatComposer.tsx | 10 +- .../NativeChatInteractiveCard.test.tsx | 329 +++++++++++++--- .../native-chat/NativeChatInteractiveCard.tsx | 178 +++++---- .../native-chat/NativeChatPromptCollapse.tsx | 67 ++++ .../native-chat/NativeChatQuestionCard.tsx | 50 ++- ...ResolvedView.prompt-card-composer.test.tsx | 243 ++++++++++++ ...hatResolvedView.prompt-card-input.test.tsx | 309 +++++++++++++++ .../native-chat/NativeChatResolvedView.tsx | 67 ++-- ...ativeChatResolvedView.turn-status.test.tsx | 25 +- .../native-chat/native-chat-command-marker.ts | 2 +- .../native-chat/native-chat-composer-types.ts | 2 + .../native-chat-draft-cache.test.ts | 2 +- .../native-chat/native-chat-input-clear.ts | 6 + .../native-chat-observed-send.test.ts | 37 ++ .../native-chat/native-chat-observed-send.ts | 32 +- .../native-chat/native-chat-pending.ts | 2 +- .../native-chat-prompt-dismissals.ts | 17 + .../native-chat/native-chat-pty-send-queue.ts | 7 +- .../native-chat-runtime-send.test.ts | 46 ++- .../native-chat/native-chat-runtime-send.ts | 62 +-- .../native-chat-session-option-cache.ts | 2 +- .../use-native-chat-composer-interrupt.ts | 9 +- .../use-native-chat-composer-reveal-focus.ts | 3 +- .../use-native-chat-interactive-send.test.tsx | 34 +- .../use-native-chat-interactive-send.ts | 79 ++-- .../use-native-chat-prompt-card-focus.ts | 22 ++ ...tive-chat-prompt-card-presentation.test.ts | 87 +++++ ...se-native-chat-prompt-card-presentation.ts | 102 +++++ .../use-native-chat-send-lifecycle.ts | 49 ++- src/renderer/src/i18n/locales/en.json | 4 + src/renderer/src/i18n/locales/es.json | 4 + src/renderer/src/i18n/locales/fr.json | 4 + src/renderer/src/i18n/locales/ja.json | 4 + src/renderer/src/i18n/locales/ko.json | 4 + src/renderer/src/i18n/locales/zh.json | 4 + .../runtime-terminal-input-recording.ts | 69 ++++ .../runtime/runtime-terminal-inspection.ts | 119 +----- .../runtime-terminal-verified-input.test.ts | 106 ++++++ .../runtime-terminal-verified-input.ts | 83 ++++ .../retired-terminal-tab-state-sweep.ts | 3 + .../native-chat-scope-cache.test.ts} | 2 +- .../native-chat-scope-cache.ts} | 0 src/shared/prompt-dismissal-store.ts | 51 +++ src/shared/pty-write-settlement.ts | 1 + .../rpc-contract/terminal-unary-params.ts | 2 + src/shared/runtime-terminal-contracts.ts | 29 +- src/shared/runtime-terminal-send-contract.ts | 26 ++ src/shared/terminal-send-acknowledgment.ts | 33 ++ .../e2e/__mocks__/native-chat-ssh-delivery.ts | 213 +++++++++++ .../e2e/native-chat-ssh-delivery.unit.test.ts | 262 +++++++++++++ 99 files changed, 4868 insertions(+), 720 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx create mode 100644 mobile/src/session/MobileNativeChatCardHeaderAction.tsx create mode 100644 mobile/src/session/MobileNativeChatPromptCard.test.tsx create mode 100644 mobile/src/session/MobileNativeChatPromptStrip.tsx create mode 100644 mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts create mode 100644 mobile/src/session/__mocks__/mobile-prompt-controller.ts create mode 100644 mobile/src/session/mobile-native-chat-prompt-dismissals.ts create mode 100644 mobile/src/session/mobile-prompt-controller.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompt-cards.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts create mode 100644 mobile/src/session/use-mobile-native-chat-prompt-presentation.ts create mode 100644 src/main/ipc/pty/ipc/write-input-acceptance.test.ts create mode 100644 src/main/runtime/runtime-terminal-writer.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatPromptCollapse.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-prompt-dismissals.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-prompt-card-focus.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-input-recording.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-verified-input.test.ts create mode 100644 src/renderer/src/runtime/runtime-terminal-verified-input.ts rename src/{renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts => shared/native-chat-scope-cache.test.ts} (96%) rename src/{renderer/src/components/native-chat/native-chat-composer-scope-cache.ts => shared/native-chat-scope-cache.ts} (100%) create mode 100644 src/shared/prompt-dismissal-store.ts create mode 100644 src/shared/runtime-terminal-send-contract.ts create mode 100644 src/shared/terminal-send-acknowledgment.ts create mode 100644 tests/e2e/__mocks__/native-chat-ssh-delivery.ts create mode 100644 tests/e2e/native-chat-ssh-delivery.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 6df235c0a0f4..e11f4cfa19ec 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,231 @@ } }, "gates": [ + { + "id": "terminal-input.prompt-reply-occurrence-acknowledgment", + "title": "Terminal chat replies hide only the acknowledged prompt occurrence", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "renderer-mobile-provider-contract", + "surfaces": [ + "desktop terminal chat prompt cards", + "mobile terminal chat composer", + "verified PTY input and paced answers" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime", "mobile-relay"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon", "ssh", "remote-runtime", "mobile-relay"], + "coverageNotes": "Production mobile controller, parser, presentation, overlay, view, question, permission and composer run with native surfaces mocked on macOS. Desktop selector groups traverse the real observed-write queue, verified helper, IPC chunker, SSH provider and transport callbacks; paired sends traverse the real dispatcher and runtime writer into that provider. Local/daemon contracts and released-version terminal codecs are covered. Provider labels describe contracts, not physical deployments; physical WSL/Linux/Windows remain gaps.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/25761"], + "invariant": "A prompt occurrence leaves the input region only when an accepted reply answers it (a current host's provider acknowledgment, or an older host's whole-write acceptance as before) or the user collapses it to a strip above the composer, which writes nothing and keeps the card mounted with its partial answer; status-backed occurrences are told apart by the host wait's start, so view toggles and remounts preserve them while real clear/replacement supersedes them, and a transcript-only one is dropped when the view stops observing it; obsolete results cannot change replacements; selector groups wait for complete writes before pacing; refusal or unknown handoff never causes replay; a card retires an unsubmitted composer send without writing under it or losing its text, and the hidden composer cannot send or interrupt; Collapse or Escape folds a card without writing, never during an in-flight answer, and a new occurrence shows expanded again; the phone blocks only Send while a card is expanded and shows one card per occurrence; cancellation stops unsent groups and active dictation can stop.", + "oracle": "After acknowledged phone replies with stale host status, the exact card disappears and Send is enabled; unknown, refused or unverifiable-settlement replies retain enabled choices, while an older host's accepted reply dismisses like an acknowledged one; a same-occurrence view toggle retains pending acknowledgment and real replacements discard it. A slow 32 KiB SSH answer completes all chunks before Enter; cancellation preserves issued bytes but omits remaining groups. Paired settlement invokes the actual provider and lost callbacks remain unknown; older hosts deliver ordinary messages, launch and selector groups once and their accepted answers dismiss as before. An answered card stays hidden across a view remount; a collapsed card is a strip that frees the composer and expands back; an identical question in a new wait shows again; a composer send caught by a new card keeps its bubble without submitting or clearing the line under it.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2", + "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2", + "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2" + ], + "testFiles": [ + "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx", + "src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx", + "src/renderer/src/components/native-chat/native-chat-observed-send.test.ts", + "src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts", + "src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts", + "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx", + "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx", + "src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts", + "src/renderer/src/runtime/runtime-terminal-verified-input.test.ts", + "src/renderer/src/components/native-chat/NativeChatComposer.test.tsx", + "src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx", + "src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx", + "src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts", + "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", + "src/renderer/src/lib/agent-paste-draft.test.ts", + "src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts", + "src/renderer/src/lib/agent-followup-delivery.test.ts", + "src/main/ipc/pty/ipc/write-input-acceptance.test.ts", + "src/main/ipc/pty/ipc/write-input-user-input.test.ts", + "src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts", + "src/main/ipc/pty-write-ipc-validation.test.ts", + "src/main/providers/ssh-pty-write.test.ts", + "src/main/daemon/daemon-pty-write-settlement-recovery.test.ts", + "src/main/runtime/runtime-terminal-writer.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts", + "src/main/runtime/terminal-send-stale-leaf-liveness.test.ts", + "src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts", + "tests/e2e/native-chat-ssh-delivery.unit.test.ts", + "mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts", + "mobile/src/session/use-mobile-native-chat-controller.test.ts", + "mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts", + "mobile/src/session/MobileNativeChatView.test.ts", + "mobile/src/session/mobile-prompt-controller.test.ts", + "mobile/src/session/mobile-native-chat-permission-send.test.ts", + "mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts", + "mobile/src/session/use-mobile-native-chat-answer-send.test.ts", + "mobile/src/session/use-mobile-native-chat-message-send.test.ts", + "mobile/src/session/mobile-native-chat-send.test.ts", + "mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx", + "mobile/src/session/MobileNativeChatPromptCard.test.tsx", + "tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx", + "assertions": [ + "ignores approval A settling after answered replacement B", + "ignores a raw acknowledgment from a rebound PTY", + "sends only one cancellation, can cancel an answer, and permits retry after unknown" + ] + }, + { + "file": "mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts", + "assertions": [ + "accepted Deny hides exactly that occurrence and restores Send across reconnect", + "a late answer cannot reshow an answered replacement occurrence", + "a late answer cannot dismiss a replacement session" + ] + }, + { + "file": "src/main/ipc/pty/ipc/write-input-acceptance.test.ts", + "assertions": [ + "reports proven refusal and unknown acknowledgment separately", + "waits for each chunk and stops a paste after refusal", + "asks a pane awaiting daemon recovery to remount on either route" + ] + }, + { + "file": "mobile/src/session/mobile-prompt-controller.test.ts", + "assertions": [ + "keeps a question actionable after lost or unverifiable acknowledgment", + "acknowledges the same pending permission or Ask after a view-only toggle", + "keeps an acknowledged Deny hidden after the chat screen remounts on a lingering status", + "drops an accepted result after real prompt/session/PTY/tab/clear replacement", + "finishes an older-host selector once and dismisses it like an acknowledged answer", + "dismisses older-host approval choices like an acknowledged answer and restores Send", + "collapses a permission to a strip without writing, and expands it back", + "shows an identical heuristic question again after a new wait in terminal view", + "collapses an ask without writing and hides the heuristic card read from the same wait" + ] + }, + { + "file": "tests/e2e/native-chat-ssh-delivery.unit.test.ts", + "assertions": [ + "serializes every chunk of a healthy large SSH answer before Enter and dismissal", + "cancels remaining paced groups while an already-issued body still settles", + "paired terminal.send uses provider settlement for accepted and lost acknowledgment", + "older paired hosts keep their whole-write verdict for answers and ordinary sequences", + "older paired hosts confirm ordinary chat sends and pasted answers as before", + "finishes every older-host selector group once and settles the answer as delivered" + ] + }, + { + "file": "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx", + "assertions": [ + "keeps an in-flight message visible and never submits it into the approval", + "routes no typing to the hidden composer until the card is answered" + ] + }, + { + "file": "src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx", + "assertions": [ + "keeps an answered approval hidden across a chat view remount and a PTY rebind", + "collapses to a strip that frees the composer, and expanding gives the card the input back", + "shows an identical question again in a new wait that arrived while the view was unmounted", + "keeps a collapsed transcript question collapsed while the transcript re-reads", + "keeps a partly answered question across collapse and expand, and Escape in its text field" + ] + }, + { + "file": "src/main/runtime/runtime-terminal-writer.test.ts", + "assertions": [ + "waits for complete body settlement before Enter", + "does not send the suffix after refusal or unknown settlement", + "returns unknown after an acknowledged prefix followed by refusal or bookkeeping failure", + "preserves legacy raw body and Enter writes" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-10-05", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts --maxWorkers=2", + "result": "passed", + "durationSeconds": 232.36, + "summary": "257 tests passed across 19 explicit files, covering production cards, observed sends, existing launch/paste callers, IPC validation, SSH acknowledgment and daemon settlement recovery." + }, + { + "date": "2026-10-05", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts --maxWorkers=2", + "result": "passed", + "durationSeconds": 10.32, + "summary": "73 tests passed across four explicit files after the controller extraction, covering the production view/card/composer, occurrence dismissal, controller session scoping, unknown/refused replies and stopping active dictation." + }, + { + "date": "2026-10-06", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx src/renderer/src/components/native-chat/use-native-chat-interactive-send.test.tsx src/renderer/src/components/native-chat/native-chat-observed-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-input.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx src/renderer/src/components/native-chat/use-native-chat-prompt-card-presentation.test.ts src/renderer/src/runtime/runtime-terminal-verified-input.test.ts src/renderer/src/components/native-chat/NativeChatComposer.test.tsx src/renderer/src/components/native-chat/NativeChatResolvedView.turn-status.test.tsx src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/lib/agent-paste-draft.test.ts src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts src/renderer/src/lib/agent-followup-delivery.test.ts src/main/ipc/pty/ipc/write-input-acceptance.test.ts src/main/ipc/pty/ipc/write-input-user-input.test.ts src/main/ipc/pty/ipc/write-input-chunk-yield.test.ts src/main/ipc/pty-write-ipc-validation.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/daemon-pty-write-settlement-recovery.test.ts src/main/runtime/runtime-terminal-writer.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-send-launch-draft-resolution.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts tests/e2e/native-chat-ssh-delivery.unit.test.ts --maxWorkers=2", + "result": "passed", + "durationSeconds": 14.53, + "summary": "391 tests passed across 29 explicit files after the round-4 review fix, including older-host acceptance, the inert hidden composer, collapse that keeps a partial answer and returns focus to the composer, pane-scoped card focus, wait-start occurrence identity and the pane remount signal." + }, + { + "date": "2026-10-06", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 mobile/node_modules/.bin/vitest run --config mobile/vitest.config.ts mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts mobile/src/session/use-mobile-native-chat-controller.test.ts mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts mobile/src/session/MobileNativeChatView.test.ts mobile/src/session/mobile-prompt-controller.test.ts mobile/src/session/mobile-native-chat-permission-send.test.ts mobile/src/session/use-mobile-native-chat-cancel-ask.test.ts mobile/src/session/use-mobile-native-chat-answer-send.test.ts mobile/src/session/use-mobile-native-chat-message-send.test.ts mobile/src/session/mobile-native-chat-send.test.ts mobile/src/session/MobileNativeChatCardHeaderAction.test.tsx mobile/src/session/MobileNativeChatPromptCard.test.tsx --maxWorkers=2", + "result": "passed", + "durationSeconds": 1.19, + "summary": "186 tests passed across 12 explicit files after the round-3 review fixes, including older-host answers dismissing like acknowledged ones, collapse that keeps the card mounted, one card per occurrence, wait-start question identity and only Send blocked." + }, + { + "date": "2026-10-06", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 25.68, + "summary": "10 terminal wire codec compatibility cases passed against current and two supported released source versions; this is a codec/RPC-reader contract, not a physical mixed-version paired deployment." + } + ], + "runtimeBudget": { + "p95Seconds": 300, + "scope": "Focused desktop and mobile unit/contract commands on the shared machine; CI p95 is not established." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local regression runs passed; cross-platform CI soak has not started." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Immutable 8b2538 baseline reproduced Enter overtaking the SSH body and paired raw acceptance without provider settlement. Desired-behavior ablations replacing only baseline mobile sender/occurrence modules fail five of fourteen cases; replacing selector scheduling fails one of six; replacing paired runtime writer fails two of six. The entire gate has not been reverted, and rendered/platform soak remains absent." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, subprocess, startup await, new status producer or dismissal timer. Reuses the existing bounded per-tab committed observation and per-PTY queue; waits for each group before its existing pacing timer. Duplicate-cancel and yielding chunk tests bound writes. Optional paired proof reuses provider settlement only for requesting clients; legacy requests keep their raw path." + }, + "knownGaps": [ + "Provider acknowledgment proves handoff, not agent consumption or prompt closure; a host prompt gate remains deferred pending captured closing lifecycles.", + "Rendered Electron and physical mobile/microphone behavior require remote QA; native surfaces are mocked in these unit tests.", + "Physical WSL, Linux/Windows, paired mixed-version deployment and network fault campaigns have not been exercised.", + "Older hosts provide whole-write handoff only; their accepted answers dismiss the card as before, without provider acknowledgment.", + "A transcript-only question has no wait start: after the view stops observing it, a collapse or answer is dropped and the card shows again.", + "Cancelling a paced answer stops future groups; bytes already handed to the provider, including pending chunks, can still settle." + ], + "promotionCriteria": [ + "Record change-reverted red/green evidence and repeatable remote rendered approval/question/dictation journeys.", + "Collect physical provider/platform and CI soak evidence before promotion." + ], + "demotionRule": "Keep experimental until the missing evidence is collected; investigate failures without weakening occurrence, acknowledgment, cancellation or refusal assertions." + }, { "id": "agent-status.claude-task-wakeup-cycle", "title": "Claude task wake-ups cannot finish an automation before its lead turn", @@ -8720,7 +8945,7 @@ "platform": "macos", "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts", "result": "passed", - "durationSeconds": 1.31, + "durationSeconds": 1.18, "summary": "4 test files and 27 tests passed locally, including the existing daemon handler contract and a real 100-terminal Zustand cleanup fixture." } ], diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 9ba114966a87..deee669865c0 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5757,7 +5757,7 @@ "src/renderer/src/components/native-chat/native-chat-composer-containment.test.ts": 38, "src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx": 1320, "src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx": 622, - "src/renderer/src/components/native-chat/native-chat-composer-scope-cache.test.ts": 47, + "src/shared/native-chat-scope-cache.test.ts": 47, "src/renderer/src/components/native-chat/native-chat-composer-state.test.ts": 98, "src/renderer/src/components/native-chat/native-chat-composer-workspace-file-drop.test.tsx": 1742, "src/renderer/src/components/native-chat/native-chat-context-usage-summary.test.ts": 54, diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index 33c9311715ed..228b266de9ec 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -4,6 +4,7 @@ import { Check } from 'lucide-react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size' +import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction' type Props = { prompt: AskPrompt @@ -12,6 +13,8 @@ type Props = { * option's stable number instead of pasted label text (STA-1860). */ onAnswer: (selections: AskAnswerSelection[]) => Promise onCancel?: () => Promise + /** Fold the card to a strip and free Send, writing nothing. */ + onCollapse?: () => void } // Sentinel index for the free-text "Other…" row (never a real option index). @@ -21,7 +24,12 @@ const OTHER = -1 * question per step with tabs across the top, a Next button that advances (Send * on the last step), and a Cancel that dismisses the prompt. Neutral styling * with a subtle green accent on the active choice to match the rest of the app. */ -export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element { +export function MobileNativeChatAsk({ + prompt, + onAnswer, + onCancel, + onCollapse +}: Props): React.JSX.Element { const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) @@ -127,7 +135,10 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac ) : null} - {q.question} + + {q.question} + + {q.options.map((opt, optIndex) => ( ({ + Keyboard: { dismiss: vi.fn() }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Platform: { OS: 'ios', select: (o: Record) => o.ios } +})) +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronUp: 'ChevronUp', + ShieldQuestion: 'ShieldQuestion', + X: 'X' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) + +describe('rendered prompt card collapse controls', () => { + it('collapses an Ask from its header chevron without cancelling or answering it', async () => { + const onCollapse = vi.fn() + const onCancel = vi.fn(async () => true) + const onAnswer = vi.fn(async () => true) + let tree: ReactTestRenderer | null = null + await act(async () => { + tree = create( + createElement(MobileNativeChatAsk, { + prompt: { + questions: [{ question: 'Pick?', options: [{ label: 'East' }], multiSelect: false }] + }, + onAnswer, + onCancel, + onCollapse + }) + ) + }) + act(() => tree!.root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress()) + expect(onCollapse).toHaveBeenCalledOnce() + // The reply field the collapse hides must not keep the keyboard. + expect(Keyboard.dismiss).toHaveBeenCalledOnce() + expect(onCancel).not.toHaveBeenCalled() + expect(onAnswer).not.toHaveBeenCalled() + act(() => tree!.unmount()) + }) + + it('shows Collapse in a terminal permission header and keeps Cancel where the lane can cancel', async () => { + const permission = { + title: 'Allow Bash?', + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: '\x1b' } + ] + } + const onCollapse = vi.fn() + const onCancel = vi.fn(async () => true) + let tree: ReactTestRenderer | null = null + await act(async () => { + tree = create( + createElement(MobileNativeChatPermission, { + permission, + onRespond: async () => true, + onCollapse + }) + ) + }) + expect(tree!.root.findAllByProps({ accessibilityLabel: 'Cancel' })).toHaveLength(0) + act(() => tree!.root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress()) + expect(onCollapse).toHaveBeenCalledOnce() + await act(async () => { + tree!.update( + createElement(MobileNativeChatPermission, { + permission, + onRespond: async () => true, + onCancel, + onCollapse + }) + ) + }) + expect(tree!.root.findAllByProps({ accessibilityLabel: 'Collapse' })).toHaveLength(0) + act(() => tree!.root.findByProps({ accessibilityLabel: 'Cancel' }).props.onPress()) + expect(onCancel).toHaveBeenCalledOnce() + act(() => tree!.unmount()) + }) +}) diff --git a/mobile/src/session/MobileNativeChatCardHeaderAction.tsx b/mobile/src/session/MobileNativeChatCardHeaderAction.tsx new file mode 100644 index 000000000000..4c7fa3ef4ad8 --- /dev/null +++ b/mobile/src/session/MobileNativeChatCardHeaderAction.tsx @@ -0,0 +1,45 @@ +import { Keyboard, Pressable, StyleSheet } from 'react-native' +import { ChevronDown, X } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' + +/** A prompt card's header action: Cancel where the lane can cancel, else a Collapse that writes nothing. */ +export function MobileNativeChatCardHeaderAction({ + prompt, + onCancel, + onCollapse, + disabled +}: { + prompt?: Prompt + onCancel?: (prompt?: Prompt) => Promise + onCollapse?: () => void + disabled?: boolean +}): React.JSX.Element | null { + if (!onCancel && !onCollapse) { + return null + } + const Icon = onCancel ? X : ChevronDown + return ( + { + if (onCancel) { + void onCancel(prompt) + return + } + // Why: a reply field hidden by the collapse must not keep the keyboard up. + Keyboard.dismiss() + onCollapse?.() + }} + disabled={disabled} + > + + + ) +} + +const styles = StyleSheet.create({ + action: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' } +}) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index ae05d9622c1b..34adc23b83ea 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -68,6 +68,8 @@ type Props = { onMicPressIn?: () => void onMicPressOut?: () => void disabled?: boolean + /** Only Send is unavailable; typing, dictation and attachments still edit the draft. */ + sendDisabled?: boolean placeholder?: string filePaths?: string[] onNeedFiles?: (query: string) => void @@ -94,6 +96,7 @@ export function MobileNativeChatComposer({ onMicPressIn, onMicPressOut, disabled = false, + sendDisabled = false, placeholder = 'Message, @files, /commands', filePaths = NO_FILE_PATHS, onNeedFiles @@ -123,6 +126,7 @@ export function MobileNativeChatComposer({ const canSend = (trimmed.length > 0 || attachments.length > 0) && !disabled && + !sendDisabled && !sending && !isAttaching && !sessionOptionDispatching @@ -303,7 +307,7 @@ export function MobileNativeChatComposer({ onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined} onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined} onLongPress={dictationMode === 'hold' ? keepHeldPressThroughLongPress : undefined} - disabled={disabled} + disabled={disabled && !micActive} > {/* The icon swaps on press; as the page's touch target, its removal would send touchend to a detached node and lose the release. */} diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index d3d6632b68e9..3d08c8d66bd2 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -93,10 +93,14 @@ export function MobileNativeChatOverlay({ onStop={controller.handleNativeChatStop} ask={controller.nativeChatAsk} askKey={controller.nativeChatAskKey} + promptKey={controller.nativeChatPromptKey} onDismissAsk={controller.dismissNativeChatAsk} onAnswerAsk={controller.handleNativeChatAnswerAsk} onCancelAsk={controller.handleNativeChatCancelAsk} onCancelPrompt={controller.handleNativeChatCancelPrompt} + onCollapseAsk={controller.collapseNativeChatAsk} + onCollapsePrompt={controller.collapseNativeChatPrompt} + collapsedPrompt={controller.nativeChatCollapsedPrompt} question={controller.nativeChatQuestion} onAnswerQuestion={controller.handleNativeChatQuestionAnswer} permission={controller.nativeChatPermission} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 9d82f254ffce..ccf579f739f9 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -11,7 +11,12 @@ vi.mock('react-native', () => ({ View: 'View' })) -vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion', X: 'X' })) +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronUp: 'ChevronUp', + ShieldQuestion: 'ShieldQuestion', + X: 'X' +})) vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' })) describe('MobileNativeChatPermission', () => { diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index ab7dda7374e0..84a6048540b7 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -1,6 +1,7 @@ import { memo, useRef, useState } from 'react' import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' -import { ShieldQuestion, X } from 'lucide-react-native' +import { ShieldQuestion } from 'lucide-react-native' +import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction' import { MobileMarkdown } from '../components/MobileMarkdown' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { @@ -15,11 +16,14 @@ import type { MobileChatPermission } from './mobile-native-chat-permission' function MobileNativeChatPermissionImpl({ permission, onRespond, - onCancel + onCancel, + onCollapse }: { permission: MobileChatPermission onRespond: (send: string) => Promise onCancel?: (prompt?: NonNullable) => Promise + /** Fold the card to a strip and free Send, writing nothing. */ + onCollapse?: () => void }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) @@ -57,17 +61,12 @@ function MobileNativeChatPermissionImpl({ > {permission.title} - {onCancel ? ( - void onCancel(permission.prompt)} - disabled={submitting} - > - - - ) : null} +
{hasContext ? ( ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + TextInput: 'TextInput', + View: 'View', + Platform: { OS: 'ios', select: (o: Record) => o.ios } +})) +vi.mock('lucide-react-native', () => ({ + Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronUp: 'ChevronUp', + CircleHelp: 'CircleHelp', + ShieldQuestion: 'ShieldQuestion', + X: 'X' +})) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) + +const textInput = (root: ReactTestInstance) => + root.find((node) => node.props.placeholder === 'Type your answer') +const ask = { questions: [{ question: 'Pick?', options: [{ label: 'East' }], multiSelect: false }] } + +describe('MobileNativeChatPromptCard collapse', () => { + it('keeps a partly answered Ask mounted but hidden while collapsed', async () => { + let tree: ReactTestRenderer | null = null + const render = (collapsed: boolean) => + createElement(MobileNativeChatPromptCard, { + ask, + collapsedPrompt: collapsed ? { title: 'Pick?', expand: () => {} } : null + }) + await act(async () => { + tree = create(render(false)) + }) + const other = tree!.root.findAll((node) => node.props.label === 'Other…')[0]! + act(() => other.props.onPress()) + act(() => textInput(tree!.root).props.onChangeText('turbo')) + + await act(async () => tree!.update(render(true))) + expect(tree!.root.findByProps({ testID: 'native-chat-prompt-strip' })).toBeTruthy() + const hidden = tree!.root.findAll((node) => node.props.style?.display === 'none') + expect(hidden).toHaveLength(1) + expect(textInput(hidden[0]!).props.value).toBe('turbo') + + await act(async () => tree!.update(render(false))) + expect(textInput(tree!.root).props.value).toBe('turbo') + act(() => tree!.unmount()) + }) +}) diff --git a/mobile/src/session/MobileNativeChatPromptCard.tsx b/mobile/src/session/MobileNativeChatPromptCard.tsx index 31a007801dc5..210c91839622 100644 --- a/mobile/src/session/MobileNativeChatPromptCard.tsx +++ b/mobile/src/session/MobileNativeChatPromptCard.tsx @@ -1,14 +1,16 @@ +import { StyleSheet, View } from 'react-native' import type { AskAnswerSelection, AskPrompt } from '../../../src/shared/native-chat-ask' import { MobileNativeChatAsk } from './MobileNativeChatAsk' import { MobileNativeChatPermission } from './MobileNativeChatPermission' import type { MobileChatPermission } from './mobile-native-chat-permission' import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' +import { MobileNativeChatPromptStrip } from './MobileNativeChatPromptStrip' /** The one pending agent prompt shown above the composer: a structured * AskUserQuestion wins, then a heuristic permission, then a heuristic question. * The controller owns dismissal (it must survive this subtree unmounting on a - * view toggle); `ask` arrives already nulled while dismissed. */ + * view toggle); `ask` arrives already nulled once answered. */ export function MobileNativeChatPromptCard({ ask, askKey, @@ -16,6 +18,9 @@ export function MobileNativeChatPromptCard({ onAnswerAsk, onCancelAsk, onCancelPrompt, + onCollapseAsk, + onCollapsePrompt, + collapsedPrompt, permission, onRespondPermission, question, @@ -27,52 +32,66 @@ export function MobileNativeChatPromptCard({ onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise onCancelAsk?: () => Promise onCancelPrompt?: (prompt?: NonNullable) => Promise + onCollapseAsk?: () => void + /** Fold the permission/question occurrence to a strip and free Send, writing nothing. */ + onCollapsePrompt?: () => void + /** Set while the shown occurrence is collapsed: its card stays mounted but hidden under a strip. */ + collapsedPrompt?: { title: string; expand: () => void } | null permission?: MobileChatPermission | null onRespondPermission?: (send: string) => Promise question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise }): React.JSX.Element | null { - if (ask) { - return ( - { - const accepted = (await onAnswerAsk?.(ask, selections)) ?? false - if (accepted) { - onDismissAsk?.() - } - return accepted - }} - onCancel={async () => { - const accepted = (await onCancelAsk?.()) ?? false - if (accepted) { - onDismissAsk?.() - } - return accepted - }} - /> - ) - } - if (permission) { - return ( - (await onRespondPermission?.(send)) ?? false} - onCancel={onCancelPrompt} - /> - ) - } - if (question) { - return ( - (await onAnswerQuestion?.(text)) ?? false} - onCancel={onCancelPrompt} - /> - ) - } - return null + const card = ask ? ( + { + const accepted = (await onAnswerAsk?.(ask, selections)) ?? false + if (accepted) { + onDismissAsk?.() + } + return accepted + }} + onCancel={async () => { + const accepted = (await onCancelAsk?.()) ?? false + if (accepted) { + onDismissAsk?.() + } + return accepted + }} + onCollapse={onCollapseAsk} + /> + ) : permission ? ( + (await onRespondPermission?.(send)) ?? false} + onCancel={onCancelPrompt} + onCollapse={onCollapsePrompt} + /> + ) : question ? ( + (await onAnswerQuestion?.(text)) ?? false} + onCancel={onCancelPrompt} + onCollapse={onCollapsePrompt} + /> + ) : null + // Why one stable wrapper: collapsing hides the card without remounting it, so a partly answered + // card keeps its selections. + return ( + <> + {card} + {collapsedPrompt ? ( + + ) : null} + + ) } + +const styles = StyleSheet.create({ collapsed: { display: 'none' } }) diff --git a/mobile/src/session/MobileNativeChatPromptStrip.tsx b/mobile/src/session/MobileNativeChatPromptStrip.tsx new file mode 100644 index 000000000000..89b0fab2bc35 --- /dev/null +++ b/mobile/src/session/MobileNativeChatPromptStrip.tsx @@ -0,0 +1,45 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { ChevronUp } from 'lucide-react-native' +import { colors, spacing, typography } from '../theme/mobile-theme' + +/** A collapsed prompt above the usable composer; expanding gives it Send again. */ +export function MobileNativeChatPromptStrip({ + title, + onExpand +}: { + title: string + onExpand: () => void +}): React.JSX.Element { + return ( + + + {title} + + + + + + ) +} + +const styles = StyleSheet.create({ + action: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }, + strip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingLeft: spacing.md, + paddingRight: spacing.sm, + paddingVertical: spacing.xs, + backgroundColor: colors.bgPanel, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + title: { flex: 1, color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' } +}) diff --git a/mobile/src/session/MobileNativeChatQuestion.test.tsx b/mobile/src/session/MobileNativeChatQuestion.test.tsx index 96cae6556a4d..5c877dabb3ff 100644 --- a/mobile/src/session/MobileNativeChatQuestion.test.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.test.tsx @@ -14,6 +14,8 @@ vi.mock('react-native', () => ({ vi.mock('lucide-react-native', () => ({ ArrowUp: 'ArrowUp', Check: 'Check', + ChevronDown: 'ChevronDown', + ChevronUp: 'ChevronUp', CircleHelp: 'CircleHelp', X: 'X' })) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index f5f9e69cb8ec..314e3efd4a12 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState } from 'react' import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { ArrowUp, Check, CircleHelp, X } from 'lucide-react-native' +import { ArrowUp, Check, CircleHelp } from 'lucide-react-native' +import { MobileNativeChatCardHeaderAction } from './MobileNativeChatCardHeaderAction' import { colors, radii, spacing, typography } from '../theme/mobile-theme' import { mobileNativeChatInputStyles } from './mobile-native-chat-input-styles' import { @@ -14,6 +15,8 @@ type Props = { question: MobileChatQuestion onAnswer: (text: string) => Promise onCancel?: (prompt?: NonNullable) => Promise + /** Fold the card to a strip and free Send, writing nothing. */ + onCollapse?: () => void } /** Renders an agent's choice prompt as a tappable card. Single-select answers @@ -23,7 +26,8 @@ type Props = { export function MobileNativeChatQuestion({ question, onAnswer, - onCancel + onCancel, + onCollapse }: Props): React.JSX.Element { const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([]) const [freeText, setFreeText] = useState('') @@ -108,17 +112,12 @@ export function MobileNativeChatQuestion({ {question.question} - {onCancel ? ( - void onCancel(question.prompt)} - disabled={sending} - > - - - ) : null} + {hasOptions ? ( @@ -231,12 +230,6 @@ const styles = StyleSheet.create({ fontWeight: '600', lineHeight: typography.bodySize + 7 }, - cancel: { - width: 28, - height: 28, - alignItems: 'center', - justifyContent: 'center' - }, options: { gap: spacing.xs }, diff --git a/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts new file mode 100644 index 000000000000..cfd1157ab273 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.prompt-presentation.test.ts @@ -0,0 +1,229 @@ +import { createElement, type ComponentProps } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { Pressable } from 'react-native' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatView } from './MobileNativeChatView' +import { useMobileNativeChatPromptPresentation } from './use-mobile-native-chat-prompt-presentation' +import type { MobileChatPermission } from './mobile-native-chat-permission' +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Image: 'Image', + Platform: { OS: 'ios' }, + Keyboard: { dismiss: vi.fn() }, + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + type GestureChain = { + runOnJS: () => GestureChain + onStart: () => GestureChain + onUpdate: () => GestureChain + } + const chain: GestureChain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => + Object.fromEntries( + [ + 'ArrowDown', + 'ArrowUp', + 'ChevronDown', + 'ChevronUp', + 'ChevronsDownUp', + 'ChevronsUpDown', + 'ShieldQuestion', + 'ImagePlus', + 'Mic', + 'Square', + 'X' + ].map((x) => [x, x]) + ) +) +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('./MobileNativeChatSessionOptionPickers', () => ({ + MobileNativeChatSessionOptionPickers: 'SessionOptions' +})) +vi.mock('./MobileNativeChatComposerSuggestions', () => ({ + MobileNativeChatComposerSuggestions: 'Suggestions', + composerSuggestionInsertText: () => '' +})) +vi.mock('../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) +let tree: ReactTestRenderer | null = null +const props = { + messages: [], + folded: [], + status: 'ready' as const, + streaming: null, + pending: [], + onSend: vi.fn(async () => true), + sendSurfaceId: 'terminal-1', + getSendCompletionGeneration: () => 0, + getComposerEditGeneration: () => 0, + composerText: 'no, do X instead', + onComposerTextChange: vi.fn() +} +const permission: MobileChatPermission = { + title: 'Claude wants to use Bash', + options: [{ label: 'Deny', send: String.fromCharCode(27) }] +} +type Overrides = Partial> & { + waitStartedAt?: number + sessionKey?: string +} +function Harness(overrides: Overrides) { + const shown = useMobileNativeChatPromptPresentation({ + permission: overrides.permission ?? null, + question: overrides.question ?? null, + waitStartedAt: overrides.waitStartedAt ?? 10, + scopeKey: 'tab-1', + sessionKey: overrides.sessionKey ?? 'session-1', + observing: true, + respondPermission: overrides.onRespondPermission ?? (async () => false), + answerQuestion: overrides.onAnswerQuestion ?? (async () => false) + }) + return createElement(MobileNativeChatView, { + ...props, + ...overrides, + permission: shown.permission, + question: shown.question, + promptKey: shown.occurrenceKey, + onRespondPermission: shown.respondPermission, + onAnswerQuestion: shown.answerQuestion + }) +} +const render = async (overrides: Overrides) => { + await act(async () => { + const element = createElement(Harness, overrides) + if (tree) { + tree.update(element) + } else { + tree = create(element) + } + }) +} +const sendButton = () => tree!.root.findByProps({ accessibilityLabel: 'Send message' }) +const approvalAction = () => + tree!.root.findByProps({ testID: 'native-chat-approval-actions' }).findByType(Pressable) +afterEach(() => { + act(() => tree?.unmount()) + tree = null + vi.unstubAllGlobals() +}) +describe('terminal prompt presentation with the production view, card and composer', () => { + it('accepted Deny hides exactly that occurrence and restores Send across reconnect', async () => { + vi.stubGlobal('requestAnimationFrame', (callback: () => void) => setTimeout(callback, 0)) + const onRespondPermission = vi.fn(async () => true) + await render({ permission, onRespondPermission }) + expect(sendButton().props.disabled).toBe(true) + await act(async () => { + await approvalAction().props.onPress() + }) + expect(onRespondPermission).toHaveBeenCalledExactlyOnceWith(String.fromCharCode(27)) + expect(tree!.root.findAllByProps({ testID: 'native-chat-approval-actions' })).toHaveLength(0) + expect(sendButton().props.disabled).toBe(false) + await render({ permission, onRespondPermission, inputLockReason: 'disconnected' }) + await render({ permission, onRespondPermission, inputLockReason: null }) + expect(sendButton().props.disabled).toBe(false) + await render({ permission, onRespondPermission, waitStartedAt: 20 }) + expect(sendButton().props.disabled).toBe(true) + expect(approvalAction().props.disabled).toBe(false) + }) + + it('refused or unknown answers keep the choices enabled', async () => { + const onRespondPermission = vi.fn(async () => false) + await render({ permission, onRespondPermission }) + await act(async () => { + await approvalAction().props.onPress() + }) + expect(approvalAction().props.disabled).toBe(false) + expect(sendButton().props.disabled).toBe(true) + }) + + it('a late answer cannot reshow an answered replacement occurrence', async () => { + let finishOld: (accepted: boolean) => void = () => {} + const onRespondPermission = vi + .fn<(send: string) => Promise>() + .mockReturnValueOnce( + new Promise((resolve) => { + finishOld = resolve + }) + ) + .mockResolvedValueOnce(true) + await render({ permission, onRespondPermission }) + act(() => { + void approvalAction().props.onPress() + }) + await render({ permission, onRespondPermission, waitStartedAt: 20 }) + await act(async () => { + await approvalAction().props.onPress() + }) + expect(sendButton().props.disabled).toBe(false) + await act(async () => finishOld(true)) + expect(sendButton().props.disabled).toBe(false) + }) + + it('a late answer cannot dismiss a replacement session', async () => { + let finishOld: (accepted: boolean) => void = () => {} + const onRespondPermission = vi.fn( + () => + new Promise((resolve) => { + finishOld = resolve + }) + ) + await render({ permission, onRespondPermission }) + act(() => { + void approvalAction().props.onPress() + }) + await render({ permission, onRespondPermission, sessionKey: 'session-2' }) + await act(async () => finishOld(true)) + expect(sendButton().props.disabled).toBe(true) + expect(approvalAction().props.disabled).toBe(false) + }) + + it.each(['toggle', 'hold'])( + 'keeps %s dictation and attachments available while a prompt blocks Send', + async (dictationMode) => { + const onMicPress = vi.fn() + const onMicPressOut = vi.fn() + await render({ micActive: true, onMicPress, onMicPressOut, dictationMode }) + await render({ permission, micActive: true, onMicPress, onMicPressOut, dictationMode }) + const stop = tree!.root.findByProps({ accessibilityLabel: 'Stop dictation' }) + expect(stop.props.disabled).toBe(false) + act(() => { + ;(dictationMode === 'hold' ? stop.props.onPressOut : stop.props.onPress)() + }) + expect(dictationMode === 'hold' ? onMicPressOut : onMicPress).toHaveBeenCalledOnce() + await render({ permission, onMicPress, dictationMode, onAttachImage: vi.fn() }) + expect(tree!.root.findByProps({ accessibilityLabel: 'Dictate' }).props.disabled).toBe(false) + expect(tree!.root.findByProps({ accessibilityLabel: 'Attach image' }).props.disabled).toBe( + false + ) + expect(sendButton().props.disabled).toBe(true) + } + ) +}) diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d96c9179b08b..cf9be553e798 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -714,4 +714,36 @@ describe('MobileNativeChatView', () => { vi.useRealTimers() } }) + // Why: a terminal-backed send types into the agent's prompt and could answer it; drafting cannot. + describe('a prompt card owns Send in terminal-backed chat', () => { + const permission = { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] } + + it('blocks only Send while the card shows, and frees it when the card clears', async () => { + await render({ permission }) + expect(composer().props.sendDisabled).toBe(true) + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + + await update({ permission: null }) + expect(composer().props.sendDisabled).toBe(false) + }) + + it('blocks Send for an ask and a heuristic question too', async () => { + await render({ + ask: { + questions: [{ question: 'Tabs?', multiSelect: false, options: [{ label: 'Tabs' }] }] + } + }) + expect(composer().props.sendDisabled).toBe(true) + await update({ + question: { question: 'Name?', options: [], multiSelect: false, optionTokens: [] } + }) + expect(composer().props.sendDisabled).toBe(true) + }) + + it('leaves a structured chat composer open: its host queues the send behind the prompt', async () => { + await render({ permission, structuredActivityUi: true }) + expect(composer().props.sendDisabled).toBe(false) + }) + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a559bd02ef25..a9b4a2f2191c 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -29,7 +29,10 @@ import { import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture' import { useMobileNativeChatTailFollow } from './use-mobile-native-chat-tail-follow' import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure' -import { useSettledMobileNativeChatInputLock } from './use-mobile-native-chat-input-lease' +import { + mobileNativeChatComposerPlaceholder, + useSettledMobileNativeChatInputLock +} from './use-mobile-native-chat-input-lease' import { MobileNativeChatLiveLine } from './MobileNativeChatLiveLine' import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator' import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' @@ -131,9 +134,13 @@ type Props = MobileQueuedSlotProps & { onCancelAsk?: () => Promise /** Cancel a structured approval/question with exact item identity when supported. */ onCancelPrompt?: (prompt?: { itemId: string; expectedRevision: number }) => Promise + onCollapseAsk?: () => void + onCollapsePrompt?: () => void + collapsedPrompt?: { title: string; expand: () => void } | null question?: MobileChatQuestion | null onAnswerQuestion?: (text: string) => Promise permission?: MobileChatPermission | null + promptKey?: string | null onRespondPermission?: (send: string) => Promise /** Open a worktree file tapped in agent markdown. */ onOpenFile?: (relativePath: string) => void @@ -190,9 +197,13 @@ export function MobileNativeChatView({ onAnswerAsk, onCancelAsk, onCancelPrompt, + onCollapseAsk, + onCollapsePrompt, + collapsedPrompt, question, onAnswerQuestion, permission, + promptKey, onRespondPermission, queuedSlot: { cards: queuedCards, composerInputRef: inputRef } = NO_QUEUED_SLOT, onOpenFile, @@ -310,6 +321,10 @@ export function MobileNativeChatView({ const showLoading = status === 'loading' && messages.length === 0 const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) + // Why only Send, terminal-backed only: that send types into the agent's prompt and can answer it, + // while drafting never does; the host queues a structured send behind it. + const expandedPromptOwnsSend = + !structuredActivityUi && !collapsedPrompt && (ask ?? permission ?? question) != null const emptyStateView = emptyState ? ( {emptyState.title} @@ -386,16 +401,10 @@ export function MobileNativeChatView({ )} {queuedCards} @@ -456,13 +465,8 @@ export function MobileNativeChatView({ onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} disabled={lockReason !== null} - placeholder={ - lockReason === 'disconnected' - ? 'Reconnecting…' - : lockReason === 'waiting' - ? 'Waiting for terminal…' - : 'Message, @files, /commands' - } + sendDisabled={expandedPromptOwnsSend} + placeholder={mobileNativeChatComposerPlaceholder(lockReason)} filePaths={filePaths} onNeedFiles={onNeedFiles} /> diff --git a/mobile/src/session/__mocks__/mobile-prompt-controller.ts b/mobile/src/session/__mocks__/mobile-prompt-controller.ts new file mode 100644 index 000000000000..397c4483ef29 --- /dev/null +++ b/mobile/src/session/__mocks__/mobile-prompt-controller.ts @@ -0,0 +1,299 @@ +import { createElement } from 'react' +import { Pressable, Text } from 'react-native' +import type { RpcClient } from '../../transport/rpc-client' +import type { RpcResponse, ConnectionState } from '../../transport/types' +import type { MobileNativeChatTab } from '../mobile-native-chat-eligibility' +import type { MobileNativeChatController } from '../mobile-native-chat-controller-contract' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { vi } from 'vitest' +import { useMobileNativeChatController } from '../use-mobile-native-chat-controller' +import { MobileNativeChatOverlay } from '../MobileNativeChatOverlay' +import { MobileNativeChatAsk } from '../MobileNativeChatAsk' +import type { AskAnswerSelection } from '../../../../src/shared/native-chat-ask' +import { mobileNativeChatPromptDismissals } from '../mobile-native-chat-prompt-dismissals' +vi.mock('react-native', async () => { + const React = await import('react') + return { + ActivityIndicator: 'ActivityIndicator', + Image: 'Image', + Platform: { OS: 'ios' }, + Keyboard: { dismiss: vi.fn() }, + FlatList: React.forwardRef((props, ref) => { + React.useImperativeHandle(ref, () => ({ scrollToEnd: vi.fn(), scrollToOffset: vi.fn() }), []) + return React.createElement('FlatList', props) + }), + Pressable: 'Pressable', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' + } +}) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + type GestureChain = { + runOnJS: () => GestureChain + onStart: () => GestureChain + onUpdate: () => GestureChain + } + const chain: GestureChain = { runOnJS: () => chain, onStart: () => chain, onUpdate: () => chain } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('lucide-react-native', () => + Object.fromEntries( + [ + 'ArrowDown', + 'ArrowUp', + 'ChevronDown', + 'ChevronUp', + 'ChevronsDownUp', + 'ChevronsUpDown', + 'ShieldQuestion', + 'ImagePlus', + 'Mic', + 'Square', + 'X', + 'Check', + 'CircleHelp' + ].map((x) => [x, x]) + ) +) +vi.mock('../MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('../MobileNativeChatLiveLine', () => ({ MobileNativeChatLiveLine: 'LiveStatus' })) +vi.mock('../MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('../MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) +vi.mock('../MobileNativeChatSessionOptionPickers', () => ({ + MobileNativeChatSessionOptionPickers: 'SessionOptions' +})) +vi.mock('../MobileNativeChatComposerSuggestions', () => ({ + MobileNativeChatComposerSuggestions: 'Suggestions', + composerSuggestionInsertText: () => '' +})) +vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' })) +vi.mock('../../components/MobileMarkdown', () => ({ MobileMarkdown: 'Markdown' })) +export const visible = { value: true } +const session = { messages: [], folded: [], status: 'ready', transcriptLoading: false } +const structured = { + session, + isWorking: false, + turnId: null, + turnIndicator: null, + workingStartedAt: null, + settledTurns: null, + turnJournal: null, + permission: null, + question: null, + queued: { cards: [] }, + respondPermission: vi.fn(async () => true), + respondQuestion: vi.fn(async () => true), + cancelPrompt: vi.fn(async () => true), + sendWithOutcome: vi.fn(async () => 'accepted'), + cancel: vi.fn(), + setStructuredOption: vi.fn(), + invokeStructuredOption: vi.fn() +} +vi.mock('../use-mobile-session-view-mode', () => ({ + useMobileSessionViewMode: () => ({ + isTabChatView: () => visible.value, + toggleTabChatView: () => { + visible.value = !visible.value + } + }) +})) +vi.mock('../use-mobile-native-chat-session-lane', () => ({ + useMobileNativeChatSessionLane: () => ({ session, structuredSession: structured }) +})) +vi.mock('../use-mobile-native-chat-drafts', () => ({ + useMobileNativeChatDrafts: () => ({ + composerText: 'ordinary message', + setComposerText: vi.fn(), + getComposerEditGeneration: () => 0, + appendComposerText: () => true, + pending: [], + imagePreviewsByMessageId: {}, + captureSendOrigin: (text: string) => ({ draftKey: 'draft', normalizedText: text }), + readSeededLaunchDraft: () => null, + readSeededLaunchDraftSeed: () => null, + clearDraftForSend: vi.fn(), + restoreRejectedDraft: vi.fn(), + acceptSend: vi.fn(), + holdUnconfirmedSend: vi.fn() + }) +})) +vi.mock('../use-mobile-native-chat-session-option-controller', () => ({ + useMobileNativeChatSessionOptionController: () => ({ + nativeChatSessionOptions: undefined, + recordCommand: vi.fn() + }) +})) +vi.mock('../use-mobile-native-chat-file-search', () => ({ + useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() }) +})) +vi.mock('../use-mobile-native-chat-queued-slot', () => ({ + NO_QUEUED_SLOT: {}, + useMobileNativeChatQueuedSlot: () => ({}) +})) +vi.mock('../use-mobile-native-chat-streaming-bubble', () => ({ + useMobileNativeChatStreamingBubble: () => null +})) + +let tree: ReactTestRenderer | null = null +let controller: MobileNativeChatController +let lastProps: HarnessProps | null = null +export const handleRef = { current: 'term-1' } +export const sendError = vi.fn() +const resolved = vi.fn() +let client: RpcClient + +export function response( + accepted = true, + outcome: 'accepted' | 'refused' | 'unverifiable' | 'legacy' = accepted ? 'accepted' : 'refused' +): RpcResponse { + return { + id: 'send', + ok: true, + result: { + send: { accepted, ...(outcome === 'legacy' ? {} : { writeSettlement: { outcome } }) } + }, + _meta: { runtimeId: 'r' } + } +} +export const baseTab = { + type: 'terminal', + launchAgent: 'claude', + agentStatus: { + agentType: 'claude', + state: 'waiting', + prompt: '', + updatedAt: 10, + paneKey: 'tab-1:leaf-1', + stateHistory: [], + stateStartedAt: 10, + providerSession: { id: 'session-1', key: 'session_id' }, + lastAssistantMessage: 'Pick destination?\n1. East\n2. West' + } +} satisfies MobileNativeChatTab +export const permissionTab = { + ...baseTab, + agentStatus: { + ...baseTab.agentStatus, + lastAssistantMessage: '', + interactivePrompt: JSON.stringify({ approval: { tool: 'Bash', summary: 'echo yes' } }) + } +} satisfies MobileNativeChatTab +type HarnessProps = { + tab?: MobileNativeChatTab + tabId?: string + connState?: ConnectionState +} +function Harness(props: HarnessProps) { + const { tab = baseTab, tabId = 'tab-1', connState = 'connected' } = props + const current = useMobileNativeChatController({ + client, + hostId: 'host', + worktreeId: 'folder:work', + activeSessionTab: tab, + activeSessionTabId: tabId, + activeHandleRef: handleRef, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + connState, + onSendError: sendError, + onSendResolved: resolved + }) + captureRenderedController(current) + return createElement(MobileNativeChatOverlay, { + controller: current, + onOpenFile: vi.fn(), + images: { + sendNativeChat: controller.handleNativeChatSend, + attachments: [], + isAttaching: false, + attachImage: async () => {}, + removeAttachment: () => {} + }, + onMicPress: vi.fn(), + onMicPressOut: vi.fn(), + onMicPressIn: vi.fn(), + micActive: false, + dictationMode: 'toggle', + inputLockReason: connState === 'connected' ? null : 'disconnected', + sendErrorMessage: null, + onClearSendError: vi.fn(), + sendSurfaceId: 'surface', + getSendCompletionGeneration: () => 0, + keyboardInset: 0 + }) +} +export async function render(props: HarnessProps = lastProps ?? {}) { + lastProps = props + await act(async () => { + const element = createElement(Harness, props) + if (tree) { + tree.update(element) + } else { + tree = create(element) + } + }) +} +export function sendButton() { + return getTree().root.findByProps({ accessibilityLabel: 'Send message' }) +} +/** Index 1 is Deny. */ +export function permissionAction(index = 1) { + return getTree() + .root.findByProps({ testID: 'native-chat-approval-actions' }) + .findAllByType(Pressable)[index] +} +export function questionOption() { + return getTree() + .root.findAllByType(Pressable) + .find((node) => node.findAllByType(Text).some((text) => text.children.includes('East'))) +} +export async function unmount() { + await act(async () => tree?.unmount()) + tree = null +} +export function reset(rpcClient: RpcClient) { + client = rpcClient + visible.value = true + handleRef.current = 'term-1' + lastProps = null + sendError.mockReset() + resolved.mockReset() + mobileNativeChatPromptDismissals.clearForTests() + vi.stubGlobal('requestAnimationFrame', (callback: (time: number) => void) => + setTimeout(() => callback(0), 0) + ) +} +export function getTree(): ReactTestRenderer { + if (!tree) { + throw new Error('not rendered') + } + return tree +} +function captureRenderedController(value: MobileNativeChatController): void { + controller = value +} +export function getController(): MobileNativeChatController { + return controller +} +export function askCancel(): Promise { + return getTree().root.findByType(MobileNativeChatAsk).props.onCancel() +} +export function askCollapse(): void { + getTree().root.findByType(MobileNativeChatAsk).props.onCollapse() +} +export function askAnswer(selections: AskAnswerSelection[]): Promise { + return getTree().root.findByType(MobileNativeChatAsk).props.onAnswer(selections) +} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 90ebecb42ebb..fab3838d3682 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -48,6 +48,8 @@ export type MobileNativeChatController = { /** Host/workspace/tab/session scope for stateful streaming suppression. */ nativeChatStreamScopeKey: string nativeChatPermission: ReturnType + /** Terminal prompt occurrence; a replacement resets only the card's local submission state. */ + nativeChatPromptKey: string | null nativeChatQuestion: ReturnType /** The pending ask, already null while dismissed (dismissal lives here so it * survives the chat-view subtree unmounting on a view toggle). */ @@ -61,6 +63,11 @@ export type MobileNativeChatController = { selections: AskAnswerSelection[] ) => Promise handleNativeChatCancelAsk: () => Promise + /** Terminal lane: fold the occurrence to a strip and free Send, writing nothing. */ + collapseNativeChatAsk: () => void + collapseNativeChatPrompt?: () => void + /** The collapsed terminal occurrence, shown as a strip above the composer. */ + nativeChatCollapsedPrompt: { title: string; expand: () => void } | null handleNativeChatCancelPrompt?: (prompt?: { itemId: string expectedRevision: number diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 1525f090029f..a3c5d3910f8b 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -45,6 +45,7 @@ describe('sendMobileNativeChatPermissionResponse', () => { terminal: 'terminal', text: '1', enter: false, + requireWriteSettlement: true, client: { id: 'phone', type: 'mobile' } }, { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts index 7f58b0ab44e0..864968b396ac 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -22,6 +22,7 @@ export function sendMobileNativeChatPermissionResponse(args: { terminal: args.terminal, text: args.text, enter: false, + requireWriteSettlement: true, ...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {}) }) } diff --git a/mobile/src/session/mobile-native-chat-prompt-dismissals.ts b/mobile/src/session/mobile-native-chat-prompt-dismissals.ts new file mode 100644 index 000000000000..42d3c5013999 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-prompt-dismissals.ts @@ -0,0 +1,12 @@ +import { createPromptDismissalStore } from '../../../src/shared/prompt-dismissal-store' + +export type MobileNativeChatPromptDismissal = { + sessionKey: string | null + promptKey: string + /** Answered: the card is gone. Collapsed: the user folded it to a strip above the composer. */ + state: 'answered' | 'collapsed' +} + +/** Each chat tab's dismissed prompt occurrence, keyed by card kind and tab scope. */ +export const mobileNativeChatPromptDismissals = + createPromptDismissalStore() diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 2a3bc5a60b2a..19a37120be34 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -4,6 +4,7 @@ import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { nativeChatTerminalWrite } from './mobile-session-write-operations' import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' +import { readTerminalSendAcknowledgment } from '../../../src/shared/terminal-send-acknowledgment' /** What a native-chat write takes, named from an operation so no module names the raw port. */ export type MobileNativeChatRpcSender = Parameters[0] @@ -24,6 +25,7 @@ type MobileNativeChatSendArgs = { /** Shared budget for a whole user action (heal → paste → text, or one selector's * keystroke sequence). Omit to give this write its own full budget. */ deadline?: number + requireWriteSettlement?: true } /** 'unknown' = the RPC failed without proof the request never reached the @@ -65,6 +67,7 @@ export async function sendMobileNativeChatMessageWithOutcome( terminal: args.terminal, text: args.text, enter: args.enter ?? true, + ...(args.requireWriteSettlement ? { requireWriteSettlement: true as const } : {}), ...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), ...(args.mobileClient ? { client: args.mobileClient } : {}) }, @@ -73,6 +76,15 @@ export async function sendMobileNativeChatMessageWithOutcome( // pins the composer for twice as long. { timeoutMs, budgetSpansConnect: true } ) + if (args.requireWriteSettlement && response.ok) { + const acknowledgment = readTerminalSendAcknowledgment(response.result) + if (acknowledgment === 'unverifiable') { + return 'unknown' + } + if (acknowledgment === 'refused') { + return 'rejected' + } + } if (nativeChatTerminalWrite.interpret(response) !== true) { return 'rejected' } @@ -103,6 +115,7 @@ export async function typeMobileNativeChatCommandWithOutcome(args: { resolvedLaunchDraft?: { text: string; createdAt: number } mobileClient?: MobileTerminalClient deadline?: number + requireWriteSettlement?: true }): Promise { let writeIndex = 0 return typeAgentTuiCommand({ @@ -115,6 +128,7 @@ export async function typeMobileNativeChatCommandWithOutcome(args: { terminal: args.terminal, text: key, enter: false, + ...(args.requireWriteSettlement ? { requireWriteSettlement: true as const } : {}), ...(isSubmit && args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}), diff --git a/mobile/src/session/mobile-prompt-controller.test.ts b/mobile/src/session/mobile-prompt-controller.test.ts new file mode 100644 index 000000000000..be14c5e2d33c --- /dev/null +++ b/mobile/src/session/mobile-prompt-controller.test.ts @@ -0,0 +1,359 @@ +import { act } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import type { RpcResponse } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { buildAskAnswerKeys, type AskPrompt } from '../../../src/shared/native-chat-ask' +import { + visible, + handleRef, + baseTab, + permissionTab, + response, + render, + reset, + unmount, + sendButton, + permissionAction, + questionOption, + getController, + askCancel, + askAnswer, + askCollapse, + sendError, + getTree +} from './__mocks__/mobile-prompt-controller' + +const client: RpcClient = { + sendRequest: vi.fn(), + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +beforeEach(() => { + vi.mocked(client.sendRequest).mockReset().mockResolvedValue(response()) + reset(client) +}) +afterEach(async () => { + await unmount() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +function option() { + const found = questionOption() + if (!found) { + throw new Error('question choice missing') + } + return found +} + +describe('prompt cards through the production controller, send contract and view', () => { + it.each(['lost', 'unverifiable'])( + 'keeps a question actionable after %s acknowledgment', + async (mode) => { + vi.mocked(client.sendRequest).mockImplementation(async (method, params) => { + if ( + method === 'terminal.send' && + typeof params === 'object' && + params !== null && + 'enter' in params && + params.enter === true + ) { + if (mode === 'lost') { + throw markRpcDeliveryUnknown(new Error('lost acknowledgment')) + } + return response(false, 'unverifiable') + } + return response() + }) + await render() + await act(async () => { + await option().props.onPress() + }) + expect(getController().nativeChatQuestion).not.toBe(null) + expect(sendButton().props.disabled).toBe(true) + expect(option().props.disabled).toBeFalsy() + } + ) + + it('hides the acknowledged question and enables ordinary Send with stale host status', async () => { + await render() + await act(async () => { + await option().props.onPress() + }) + expect(getController().nativeChatQuestion).toBe(null) + expect(sendButton().props.disabled).toBe(false) + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + expect.objectContaining({ requireWriteSettlement: true, enter: true }), + expect.any(Object) + ) + }) + + it('finishes an older-host selector once and dismisses it like an acknowledged answer', async () => { + vi.useFakeTimers() + const prompt: AskPrompt = { + questions: [{ question: 'Answer?', options: [{ label: 'A' }], multiSelect: false }] + } + const selections = [{ indices: [], other: 'custom answer' }] + const groups = buildAskAnswerKeys(prompt, selections) + vi.mocked(client.sendRequest).mockResolvedValue(response(true, 'legacy')) + await render({ + tab: { + ...baseTab, + agentStatus: { + ...baseTab.agentStatus, + lastAssistantMessage: '', + toolName: 'AskUserQuestion', + interactivePrompt: JSON.stringify(prompt) + } + } + }) + let pending: Promise | undefined + act(() => { + pending = askAnswer(selections) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(4000) + await expect(pending).resolves.toBe(true) + }) + const writes = vi + .mocked(client.sendRequest) + .mock.calls.filter(([method]) => method === 'terminal.send') + .map(([, params]) => params) + expect(writes).toEqual( + groups.map((group) => + expect.objectContaining({ + text: 'raw' in group ? group.raw : group.text, + enter: false, + requireWriteSettlement: true + }) + ) + ) + expect(getController().nativeChatAsk).toBe(null) + expect(sendButton().props.disabled).toBe(false) + expect(sendError).not.toHaveBeenCalled() + }) + + it.each([0, 1])( + 'dismisses older-host approval choice %i like an acknowledged answer and restores Send', + async (choice) => { + vi.mocked(client.sendRequest).mockResolvedValue(response(true, 'legacy')) + await render({ tab: permissionTab }) + await act(async () => { + await permissionAction(choice).props.onPress() + }) + expect(getController().nativeChatPermission).toBe(null) + expect(sendButton().props.disabled).toBe(false) + expect(sendError).not.toHaveBeenCalled() + expect(client.sendRequest).toHaveBeenCalledOnce() + } + ) + + it.each(['refused', 'unverifiable'] as const)( + 'retains permission choices on %s', + async (mode) => { + vi.mocked(client.sendRequest).mockResolvedValue(response(false, mode)) + await render({ tab: permissionTab }) + await act(async () => { + await permissionAction().props.onPress() + }) + expect(getController().nativeChatPermission).not.toBe(null) + expect(permissionAction().props.disabled).toBe(false) + expect(sendButton().props.disabled).toBe(true) + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + expect.objectContaining({ requireWriteSettlement: true, enter: false, text: '\x1b' }), + expect.any(Object) + ) + } + ) + + it.each(['permission', 'ask'])( + 'acknowledges the same pending %s after a view-only toggle', + async (kind) => { + const tab = + kind === 'permission' + ? permissionTab + : { + ...baseTab, + agentStatus: { + ...baseTab.agentStatus, + lastAssistantMessage: '', + toolName: 'AskUserQuestion', + interactivePrompt: JSON.stringify({ + questions: [ + { + question: 'Pick destination?', + options: [{ label: 'East' }, { label: 'West' }] + } + ] + }) + } + } + let finish: (reply: RpcResponse) => void = () => { + throw new Error('write not started') + } + vi.mocked(client.sendRequest).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await render({ tab }) + let action: Promise | undefined + act(() => { + action = kind === 'permission' ? permissionAction().props.onPress() : askCancel() + }) + visible.value = false + await render({ tab }) + visible.value = true + await render({ tab }) + await act(async () => { + finish(response()) + await action + }) + expect(getController().nativeChatPermission).toBe(null) + expect(getController().nativeChatAsk).toBe(null) + expect(sendButton().props.disabled).toBe(false) + } + ) + + it('collapses a permission to a strip without writing, and expands it back', async () => { + await render({ tab: permissionTab }) + act(() => { + getTree().root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress() + }) + // Still mounted under the strip, so a partly made choice survives. + expect(getController().nativeChatPermission).not.toBe(null) + expect(getController().nativeChatCollapsedPrompt?.title).toBeTruthy() + expect(getTree().root.findAllByProps({ testID: 'native-chat-prompt-strip' })).toHaveLength(1) + expect(sendButton().props.disabled).toBe(false) + act(() => { + getTree().root.findByProps({ accessibilityLabel: 'Expand' }).props.onPress() + }) + expect(getController().nativeChatPermission).not.toBe(null) + expect(sendButton().props.disabled).toBe(true) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + it('shows an identical heuristic question again after a new wait in terminal view', async () => { + await render({ tab: baseTab }) + act(() => { + getTree().root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress() + }) + visible.value = false + await render({ tab: { ...baseTab, agentStatus: { ...baseTab.agentStatus, state: 'working' } } }) + await render({ + tab: { ...baseTab, agentStatus: { ...baseTab.agentStatus, stateStartedAt: 40 } } + }) + visible.value = true + await render() + expect(getController().nativeChatQuestion).not.toBe(null) + expect(sendButton().props.disabled).toBe(true) + }) + + it('shows a collapsed prompt again as a new wait', async () => { + await render({ tab: permissionTab }) + act(() => { + getTree().root.findByProps({ accessibilityLabel: 'Collapse' }).props.onPress() + }) + await render({ + tab: { ...permissionTab, agentStatus: { ...permissionTab.agentStatus, stateStartedAt: 20 } } + }) + expect(getController().nativeChatPermission).not.toBe(null) + expect(getController().nativeChatCollapsedPrompt).toBe(null) + }) + + it('collapses an ask without writing and hides the heuristic card read from the same wait', async () => { + await render({ + tab: { + ...baseTab, + agentStatus: { + ...baseTab.agentStatus, + lastAssistantMessage: 'Before I proceed I want to confirm a choice.', + toolName: 'AskUserQuestion', + interactivePrompt: JSON.stringify({ + questions: [{ question: 'Pick?', options: [{ label: 'East' }] }] + }) + } + } + }) + act(() => { + askCollapse() + }) + expect(getController().nativeChatAsk).not.toBe(null) + expect(getController().nativeChatPermission).toBe(null) + expect(getController().nativeChatQuestion).toBe(null) + expect(getController().nativeChatCollapsedPrompt?.title).toBe('Pick?') + expect(sendButton().props.disabled).toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + it('keeps an acknowledged Deny hidden after the chat screen remounts on a lingering status', async () => { + await render({ tab: permissionTab }) + await act(async () => { + await permissionAction().props.onPress() + }) + expect(sendButton().props.disabled).toBe(false) + await unmount() + await render({ + tab: { ...permissionTab, agentStatus: { ...permissionTab.agentStatus, updatedAt: 99 } } + }) + expect(getController().nativeChatPermission).toBe(null) + expect(sendButton().props.disabled).toBe(false) + }) + + it.each(['prompt', 'session', 'PTY', 'tab', 'clear'])( + 'drops an accepted result after real %s replacement', + async (replacement) => { + let finish: (reply: RpcResponse) => void = () => { + throw new Error('write not started') + } + vi.mocked(client.sendRequest).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + await render({ tab: permissionTab }) + act(() => { + void permissionAction().props.onPress() + }) + const agentStatus: NonNullable = { + ...permissionTab.agentStatus + } + if (replacement === 'prompt') { + agentStatus.stateStartedAt = 20 + } + if (replacement === 'session') { + agentStatus.providerSession = { id: 'session-2', key: 'session_id' } + } + if (replacement === 'PTY') { + handleRef.current = 'term-2' + } + if (replacement === 'clear') { + await render({ + tab: { ...baseTab, agentStatus: { ...baseTab.agentStatus, state: 'working' } } + }) + } + await render({ + tab: { ...permissionTab, agentStatus }, + tabId: replacement === 'tab' ? 'tab-2' : 'tab-1' + }) + await act(async () => { + finish(response()) + }) + expect(getController().nativeChatPermission).not.toBe(null) + expect(permissionAction().props.disabled).toBe(false) + expect(sendButton().props.disabled).toBe(true) + } + ) +}) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 40925931d3ce..b9b2ecdf0ecb 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -30,7 +30,7 @@ import { export type MobileNativeChatAnswerSend = { /** Answer the current question(s) from the card's per-question selections. */ answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise - /** Drop any in-flight per-keystroke writes (call on Stop). */ + /** Cancel unsent groups; an already-issued write may still settle. */ cancelPending: () => void } @@ -49,8 +49,8 @@ function sanitizeAskFreeText(text: string): string { * `terminal.send` passthrough (raw text, no enter) — same contract the * permission card already uses, so old runtimes replay them verbatim (no new * RPC; keystrokes are built client-side). The scheduled wait chain is cancelled - * on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so - * a detached chain can never write PTY bytes to a stale pane. + * on a new answer, on `cancelPending` (Stop), and on unmount / session swap. + * Cancellation prevents later groups; already-issued writes retain their verdict. */ export function useMobileNativeChatAnswerSend(args: { client: RpcClient | null @@ -175,6 +175,7 @@ export function useMobileNativeChatAnswerSend(args: { terminal: handle, text: body, enter, + requireWriteSettlement: true, deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts index 49d69ab4c3bd..92101cc6f474 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AskPrompt } from '../../../src/shared/native-chat-ask' import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss' +import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals' describe('useMobileNativeChatAskDismiss', () => { let renderer: ReactTestRenderer | null = null @@ -11,6 +12,7 @@ describe('useMobileNativeChatAskDismiss', () => { beforeEach(() => { renders = 0 + mobileNativeChatPromptDismissals.clearForTests() }) afterEach(() => { diff --git a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts index beb46b45d95e..ed447e773321 100644 --- a/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts +++ b/mobile/src/session/use-mobile-native-chat-ask-dismiss.ts @@ -1,79 +1,42 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo } from 'react' import { nativeChatAskDismissKey, type AskPrompt } from '../../../src/shared/native-chat-ask' +import { useMobileNativeChatPromptDismiss } from './use-mobile-native-chat-prompt-dismiss' -type AskDismissal = { sessionKey: string | null; askKey: string } -type DetectedAsk = { sessionKey: string | null; askKey: string | null } - -/** Track the answered-ask key so the lingering live status doesn't re-show the - * same card. The agent emits a post-tool event with the same prompt right after - * an answer, so the card is hidden until a genuinely different question arrives. - * - * Owned by the controller, not the chat subtree: the overlay unmounts on a - * chat↔terminal view toggle, and a dismissal must survive that round-trip. */ +/** Keep an answered or collapsed ask so across view toggles and remounts until a real observation + * supersedes it. */ export function useMobileNativeChatAskDismiss(args: { ask: AskPrompt | null - /** Ungated prompt payload. A working/done status hides the card but does not - * prove the sticky prompt itself cleared. Required, and never defaulted to - * `ask`: reading the gated prompt as the detected one is the resurfacing bug - * this hook exists to close. */ detectedAsk: AskPrompt | null - /** Tab scope retains dismissals across tab switches. */ scopeKey: string | null - /** Provider-session identity distinguishes restarts without growing the tab map. */ sessionKey: string | null - /** True while the chat surface can actually observe the prompt. A null ask it - * cannot see — off-chat, or before a re-subscribed transcript lands — proves - * nothing and must not reset the dismissal; that reset resurfaced the card. */ observing: boolean }): { askKey: string | null showAsk: boolean + /** The collapsed ask, shown as a strip above the composer. */ + collapsedAsk: { title: string; expand: () => void } | null dismissAsk: () => void + collapseAsk: () => void } { - const { ask, detectedAsk, scopeKey, sessionKey, observing } = args - const askKey = useMemo(() => nativeChatAskDismissKey(ask), [ask]) - const detectedAskKey = useMemo(() => nativeChatAskDismissKey(detectedAsk), [detectedAsk]) - const detectedByScopeRef = useRef(new Map()) - const [dismissedByScope, setDismissedByScope] = useState>( - () => new Map() + const askKey = useMemo(() => nativeChatAskDismissKey(args.ask), [args.ask]) + const detectedPromptKey = useMemo( + () => nativeChatAskDismissKey(args.detectedAsk), + [args.detectedAsk] ) - useEffect(() => { - if (observing) { - detectedByScopeRef.current.set(scopeKey, { sessionKey, askKey: detectedAskKey }) - } - }, [observing, detectedAskKey, scopeKey, sessionKey]) - // A cleared or genuinely different detected prompt retires the old dismissal. - useEffect(() => { - if (observing) { - setDismissedByScope((previous) => { - const dismissed = previous.get(scopeKey) - if ( - dismissed === undefined || - (dismissed.sessionKey === sessionKey && dismissed.askKey === detectedAskKey) - ) { - return previous - } - const next = new Map(previous) - next.delete(scopeKey) - return next - }) - } - }, [observing, detectedAskKey, scopeKey, sessionKey]) - const dismissed = dismissedByScope.get(scopeKey) - const showAsk = - askKey !== null && !(dismissed?.sessionKey === sessionKey && dismissed.askKey === askKey) - const dismissAsk = (): void => { - const detected = detectedByScopeRef.current.get(scopeKey) - if (askKey !== null && detected?.sessionKey === sessionKey && detected.askKey === askKey) { - setDismissedByScope((previous) => { - const current = previous.get(scopeKey) - if (current?.sessionKey === sessionKey && current.askKey === askKey) { - return previous - } - return new Map(previous).set(scopeKey, { sessionKey, askKey }) - }) - } + const dismiss = useMobileNativeChatPromptDismiss({ + ...args, + kind: 'ask', + promptKey: askKey, + detectedPromptKey + }) + return { + askKey, + showAsk: dismiss.showPrompt, + collapsedAsk: + dismiss.collapsed && args.ask + ? { title: args.ask.questions[0]?.question ?? '', expand: dismiss.expandPrompt } + : null, + dismissAsk: dismiss.dismissPrompt, + collapseAsk: dismiss.collapsePrompt } - - return { askKey, showAsk, dismissAsk } } diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts index fb09f2d4aad9..fa949db146bd 100644 --- a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts @@ -10,7 +10,7 @@ export function useMobileNativeChatCancelAsk(args: { enabled: boolean handleRef: MutableRefObject deviceTokenRef: MutableRefObject - /** Drops any in-flight paced answer writes before the Escape lands. */ + /** Cancels unsent answer groups before issuing Escape. */ cancelPending: () => void onSendError: (message: string) => void }): () => Promise { @@ -29,6 +29,7 @@ export function useMobileNativeChatCancelAsk(args: { terminal: handle, text: String.fromCharCode(27), enter: false, + requireWriteSettlement: true, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index cff91f7ab8bb..4adc88dca23a 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -160,6 +160,7 @@ import { type MobileNativeChatController } from './use-mobile-native-chat-controller' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +import { mobileNativeChatPromptDismissals } from './mobile-native-chat-prompt-dismissals' const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome) @@ -668,10 +669,10 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload } beforeEach(() => { + mobileNativeChatPromptDismissals.clearForTests() viewMode.isTabChatView = () => true setTranscript('ready') - promptsState.ask = PROMPT - promptsState.detectedAsk = PROMPT + Object.assign(promptsState, { ask: PROMPT, detectedAsk: PROMPT }) act(() => { renderer = create(createElement(Harness)) }) @@ -681,8 +682,7 @@ describe('useMobileNativeChatController ask dismissal across a transcript reload act(() => renderer?.unmount()) renderer = null controller = null - promptsState.ask = null - promptsState.detectedAsk = null + Object.assign(promptsState, { ask: null, detectedAsk: null }) setTranscript('ready', 0) viewMode.isTabChatView = () => true activeTab.id = 'tab-1' diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 3b6c1e23818f..c44001a45737 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -17,6 +17,8 @@ import { useThrottledLatestValue } from './use-throttled-latest-value' import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' import { useMobileBridgeChatPromptWrites } from './use-mobile-bridge-chat-prompt-writes' import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution' +import { useMobileNativeChatPromptCards } from './use-mobile-native-chat-prompt-cards' +import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' @@ -164,14 +166,11 @@ export function useMobileNativeChatController(args: { const nativeChatTranscriptSettled = nativeChatSession.status === 'ready' || (nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0) - const { - askKey: nativeChatAskKey, - showAsk: showNativeChatAsk, - dismissAsk: dismissNativeChatAsk - } = useMobileNativeChatAskDismiss({ + const promptScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId) + const askDismissal = useMobileNativeChatAskDismiss({ ask: nativeChatAskPrompt, detectedAsk: nativeChatDetectedAsk, - scopeKey: activeSessionTabId, + scopeKey: promptScopeKey, sessionKey: activeChatSessionId, observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) }) @@ -270,14 +269,20 @@ export function useMobileNativeChatController(args: { // Card actions retire the route's held failure banner too, not just sends. const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) - const handleNativeChatRespondPermission = activeChatStructured - ? structuredNativeChat.respondPermission - : legacyHandleNativeChatRespondPermission - const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) - const structuredCancelPrompt = useNativeChatAcceptedAction( - activeChatStructured ? structuredNativeChat.cancelPrompt : async () => false, + const promptCards = useMobileNativeChatPromptCards({ + terminal: { + permission: legacyNativeChatPermission, + question: legacyQuestion, + waitStartedAt: nativeChatStatus?.stateStartedAt ?? null, + scopeKey: promptScopeKey, + sessionKey: streamIdentity, + observing: showNativeChat && !activeChatStructured && nativeChatStatus != null, + respondPermission: legacyHandleNativeChatRespondPermission, + answerQuestion: legacyHandleNativeChatQuestionAnswer + }, + structured: activeChatStructured ? structuredNativeChat : null, onSendResolved - ) + }) return { isTabChatView, @@ -304,27 +309,22 @@ export function useMobileNativeChatController(args: { nativeChatStreamingText, nativeChatStreamLive, nativeChatStreamScopeKey: streamScopeKey, - nativeChatPermission: activeChatStructured - ? structuredNativeChat.permission - : legacyNativeChatPermission, - nativeChatQuestion: activeChatStructured ? structuredNativeChat.question : legacyQuestion, - nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null, - nativeChatAskKey, - dismissNativeChatAsk, + ...promptCards, + nativeChatAsk: + !activeChatStructured && (askDismissal.showAsk || askDismissal.collapsedAsk) + ? nativeChatAskPrompt + : null, + nativeChatAskKey: askDismissal.askKey, + dismissNativeChatAsk: askDismissal.dismissAsk, + collapseNativeChatAsk: askDismissal.collapseAsk, + nativeChatCollapsedPrompt: askDismissal.collapsedAsk ?? promptCards.nativeChatCollapsedPrompt, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, - // Heuristic/legacy cards have no durable prompt identity, so keep their - // cancel affordance absent instead of exposing a dead action. - handleNativeChatCancelPrompt: activeChatStructured ? structuredCancelPrompt : undefined, - handleNativeChatRespondPermission: respond, handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop, // The inactive lane's session is starved of identity, so its cards stay empty. nativeChatQueued: structuredNativeChat.queued, nativeChatFilePaths, loadNativeChatFiles, - handleNativeChatQuestionAnswer: activeChatStructured - ? structuredNativeChat.respondQuestion - : legacyHandleNativeChatQuestionAnswer, handleNativeChatSend: activeChatStructured ? structuredNativeChatSend.send : handleNativeChatSend, diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index f1489faa9cdc..242910e8b43f 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -92,3 +92,14 @@ export function useSettledMobileNativeChatInputLock( }, [lockHeld, rawLockHeld]) return lockHeld ? (rawLockReason ?? 'waiting') : null } + +/** What the composer says while the input lease blocks it, or its normal prompt. */ +export function mobileNativeChatComposerPlaceholder( + lockReason: MobileNativeChatInputLockReason | null +): string { + return lockReason === 'disconnected' + ? 'Reconnecting…' + : lockReason === 'waiting' + ? 'Waiting for terminal…' + : 'Message, @files, /commands' +} diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 53be0f16bdae..2d8615f5dd38 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -92,7 +92,8 @@ export function useMobileNativeChatMessageSend(args: { images: string[] | undefined, syncComposer: boolean, recordControlSend: boolean, - sharedDeadline?: number + sharedDeadline?: number, + requireWriteSettlement?: true ): Promise => { // The host writes trailing whitespace verbatim onto the agent's input line, // where it can glue the next rapid send onto this one (#14262). Only the @@ -175,6 +176,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, command: text, + ...(requireWriteSettlement ? { requireWriteSettlement } : {}), ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), ...(mobileClient ? { mobileClient } : {}), deadline @@ -183,6 +185,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + ...(requireWriteSettlement ? { requireWriteSettlement } : {}), ...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {}), deadline, ...(mobileClient ? { mobileClient } : {}) @@ -262,7 +265,7 @@ export function useMobileNativeChatMessageSend(args: { return false } try { - return (await sendMessage(text, undefined, false, true)) !== 'rejected' + return (await sendMessage(text, undefined, false, true, undefined, true)) === 'accepted' } finally { if (terminal) { releaseMobileNativeChatTerminalWrite(terminal) diff --git a/mobile/src/session/use-mobile-native-chat-prompt-cards.ts b/mobile/src/session/use-mobile-native-chat-prompt-cards.ts new file mode 100644 index 000000000000..5ddc4a0c4b5b --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompt-cards.ts @@ -0,0 +1,53 @@ +import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' +import type { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' +import { useMobileNativeChatPromptPresentation } from './use-mobile-native-chat-prompt-presentation' + +/** Select the lane's existing card actions and apply terminal-only presentation dismissal. */ +export function useMobileNativeChatPromptCards({ + terminal, + structured, + onSendResolved +}: { + terminal: Parameters[0] + structured: Pick< + ReturnType, + 'permission' | 'question' | 'respondPermission' | 'respondQuestion' | 'cancelPrompt' + > | null + onSendResolved: () => void +}): Pick< + MobileNativeChatController, + | 'nativeChatPermission' + | 'nativeChatQuestion' + | 'nativeChatPromptKey' + | 'handleNativeChatRespondPermission' + | 'handleNativeChatQuestionAnswer' + | 'handleNativeChatCancelPrompt' + | 'collapseNativeChatPrompt' + | 'nativeChatCollapsedPrompt' +> { + const respond = useNativeChatAcceptedAction( + structured?.respondPermission ?? terminal.respondPermission, + onSendResolved + ) + const cancel = useNativeChatAcceptedAction( + structured?.cancelPrompt ?? (async () => false), + onSendResolved + ) + const presentation = useMobileNativeChatPromptPresentation({ + ...terminal, + respondPermission: respond + }) + return { + nativeChatPermission: structured ? structured.permission : presentation.permission, + nativeChatQuestion: structured ? structured.question : presentation.question, + nativeChatPromptKey: structured ? null : presentation.occurrenceKey, + handleNativeChatRespondPermission: structured ? respond : presentation.respondPermission, + handleNativeChatQuestionAnswer: structured + ? structured.respondQuestion + : presentation.answerQuestion, + handleNativeChatCancelPrompt: structured ? cancel : undefined, + collapseNativeChatPrompt: structured ? undefined : presentation.collapsePrompt, + nativeChatCollapsedPrompt: structured ? null : presentation.collapsed + } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts b/mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts new file mode 100644 index 000000000000..c48d1c3ca896 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompt-dismiss.ts @@ -0,0 +1,78 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { + mobileNativeChatPromptDismissals as dismissals, + type MobileNativeChatPromptDismissal +} from './mobile-native-chat-prompt-dismissals' + +type DetectedPrompt = { sessionKey: string | null; promptKey: string | null } + +/** Presentation only: retain one answered or collapsed occurrence per tab until observations + * supersede it. */ +export function useMobileNativeChatPromptDismiss({ + kind, + promptKey, + detectedPromptKey, + scopeKey, + sessionKey, + observing +}: { + /** Ask and permission/question cards keep separate answers for the same tab. */ + kind: 'ask' | 'prompt' + promptKey: string | null + detectedPromptKey: string | null + scopeKey: string | null + sessionKey: string | null + observing: boolean +}): { + showPrompt: boolean + collapsed: boolean + dismissPrompt: () => void + collapsePrompt: () => void + expandPrompt: () => void +} { + const storeKey = JSON.stringify([kind, scopeKey]) + const detectedByScopeRef = useRef(new Map()) + const observation = useMemo(() => { + const previous = detectedByScopeRef.current.get(scopeKey) + return previous?.sessionKey === sessionKey && previous.promptKey === detectedPromptKey + ? previous + : { sessionKey, promptKey: detectedPromptKey } + }, [sessionKey, detectedPromptKey, scopeKey]) + const dismissed = useSyncExternalStore(dismissals.subscribe, () => dismissals.read(storeKey)) + useLayoutEffect(() => { + if (observing) { + detectedByScopeRef.current.set(scopeKey, observation) + } + }, [observing, detectedPromptKey, scopeKey, sessionKey, observation]) + // A cleared or genuinely different detected prompt retires the old dismissal. + useEffect(() => { + if ( + observing && + dismissed !== undefined && + !(dismissed.sessionKey === sessionKey && dismissed.promptKey === detectedPromptKey) + ) { + dismissals.forget(storeKey) + } + }, [observing, dismissed, detectedPromptKey, storeKey, sessionKey]) + const matches = + promptKey !== null && dismissed?.sessionKey === sessionKey && dismissed.promptKey === promptKey + const record = (state: MobileNativeChatPromptDismissal['state']): void => { + const detected = detectedByScopeRef.current.get(scopeKey) + if ( + promptKey !== null && + detected === observation && + detected.sessionKey === sessionKey && + detected.promptKey === promptKey + ) { + dismissals.write(storeKey, { sessionKey, promptKey, state }) + } + } + + return { + showPrompt: promptKey !== null && !matches, + collapsed: matches && dismissed?.state === 'collapsed', + dismissPrompt: () => record('answered'), + collapsePrompt: () => record('collapsed'), + expandPrompt: () => dismissals.forget(storeKey) + } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompt-presentation.ts b/mobile/src/session/use-mobile-native-chat-prompt-presentation.ts new file mode 100644 index 000000000000..c8eeaacb8561 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-prompt-presentation.ts @@ -0,0 +1,78 @@ +import { useCallback, useMemo } from 'react' +import type { MobileChatPermission } from './mobile-native-chat-permission' +import type { MobileChatQuestion } from './mobile-native-chat-question' +import { useMobileNativeChatPromptDismiss } from './use-mobile-native-chat-prompt-dismiss' + +/** Acknowledged answers hide, and the user's Collapse folds, a terminal card without changing + * host status. */ +export function useMobileNativeChatPromptPresentation({ + permission, + question, + waitStartedAt, + scopeKey, + sessionKey, + observing, + respondPermission, + answerQuestion +}: { + permission: MobileChatPermission | null + question: MobileChatQuestion | null + /** The host wait's start: identical prompts in separate waits are separate occurrences. */ + waitStartedAt: number | null + scopeKey: string | null + sessionKey: string | null + observing: boolean + respondPermission: (send: string) => Promise + answerQuestion: (text: string) => Promise +}) { + const promptKey = useMemo( + () => + permission + ? JSON.stringify(['approval', permission, waitStartedAt]) + : question + ? JSON.stringify(['question', question, waitStartedAt]) + : null, + [permission, question, waitStartedAt] + ) + const { showPrompt, collapsed, dismissPrompt, collapsePrompt, expandPrompt } = + useMobileNativeChatPromptDismiss({ + kind: 'prompt', + promptKey, + detectedPromptKey: promptKey, + scopeKey, + sessionKey, + observing + }) + const respond = useCallback( + async (send: string): Promise => { + const accepted = await respondPermission(send) + if (accepted) { + dismissPrompt() + } + return accepted + }, + [respondPermission, dismissPrompt] + ) + const answer = useCallback( + async (text: string): Promise => { + const accepted = await answerQuestion(text) + if (accepted) { + dismissPrompt() + } + return accepted + }, + [answerQuestion, dismissPrompt] + ) + return { + occurrenceKey: promptKey === null ? null : JSON.stringify([scopeKey, sessionKey, promptKey]), + permission: showPrompt || collapsed ? permission : null, + question: showPrompt || collapsed ? question : null, + collapsePrompt, + collapsed: + collapsed && (permission ?? question) + ? { title: permission?.title ?? question?.question ?? '', expand: expandPrompt } + : null, + respondPermission: respond, + answerQuestion: answer + } +} diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 58a17e77f996..e5b643846e38 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -58,8 +58,9 @@ export function useMobileNativeChatPrompts(args: { const detectedAsk = askFromStatus ?? askFromMessages return { - permission, - question, + // Why: a heuristic card read from the same waiting status would answer the Ask's dialog. + permission: askFromStatus ? null : permission, + question: askFromStatus ? null : question, detectedAsk: enabled ? detectedAsk : null, // Only the status payload needs the paused gate the approval envelope uses: // it outlives its answer, so a working/done agent must not surface one. The diff --git a/src/main/ipc/pty/ipc/write-input-acceptance.test.ts b/src/main/ipc/pty/ipc/write-input-acceptance.test.ts new file mode 100644 index 000000000000..a5577c3e03cc --- /dev/null +++ b/src/main/ipc/pty/ipc/write-input-acceptance.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + WRITE_ACCEPTED, + writeRefused, + writeUnverifiable, + type WriteSettlement +} from '../../../../shared/pty-write-settlement' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../../shared/terminal-input' +import { PtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' +import { ptyOwnership } from '../provider/ownership-state' +import type { PtyRendererDelivery } from '../session' +import { createPtyWriteInput } from './write-input' + +const { provider } = vi.hoisted(() => ({ + provider: { + hasPty: vi.fn(() => true), + write: vi.fn(), + writeWithSettlement: + vi.fn<(id: string, data: string) => WriteSettlement | Promise>() + } +})) +vi.mock('../provider/registry', () => ({ tryGetProviderForPty: () => provider })) + +const id = 'pty-acceptance' +const send = vi.fn() +const mainWindow: PtyRendererDelivery = { + isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, + webContents: { id: 1, isDestroyed: () => false, send, on: vi.fn(), removeListener: vi.fn() } +} +const input = () => createPtyWriteInput({ mainWindow }) +const write = (data: string) => + input().writePtyInputAccepted({ id, data, inputKind: 'driving', requireWriteSettlement: true }) +const paneWrite = (data: string) => + input().writePtyInputAccepted({ id, data, inputKind: 'driving' }) + +beforeEach(() => { + vi.clearAllMocks() + provider.hasPty.mockReturnValue(true) + provider.writeWithSettlement.mockReturnValue(WRITE_ACCEPTED) +}) +afterEach(() => { + ptyOwnership.delete(id) +}) + +describe('verified renderer writes reuse provider settlement', () => { + it.each(['local', 'daemon', 'WSL', 'SSH'])( + 'waits for %s acceptance without a raw duplicate', + async (host) => { + ptyOwnership.set(id, host === 'SSH' ? 'connection-1' : null) + let finish: (settlement: WriteSettlement) => void = () => {} + provider.writeWithSettlement.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve + }) + ) + let completed = false + const pending = Promise.resolve(write('\x1b')).then((accepted) => { + completed = true + return accepted + }) + await Promise.resolve() + expect(completed).toBe(false) + expect(provider.writeWithSettlement).toHaveBeenCalledExactlyOnceWith(id, '\x1b') + finish(WRITE_ACCEPTED) + await expect(pending).resolves.toBe(true) + expect(provider.write).not.toHaveBeenCalled() + } + ) + + it('reports proven refusal and unknown acknowledgment separately', async () => { + ptyOwnership.set(id, 'connection-1') + provider.writeWithSettlement.mockReturnValueOnce(writeRefused('endpoint_disconnected')) + expect(write('1')).toBe(false) + provider.writeWithSettlement.mockResolvedValueOnce( + writeUnverifiable('transport_settlement_lost', true) + ) + await expect(write('1')).rejects.toThrow('acknowledgment unavailable') + expect(provider.write).not.toHaveBeenCalled() + }) + + it('never sends an unowned or absent PTY', () => { + expect(write('1')).toBe(false) + ptyOwnership.set(id, null) + provider.hasPty.mockReturnValue(false) + expect(write('1')).toBe(false) + expect(provider.writeWithSettlement).not.toHaveBeenCalled() + }) + + it('waits for each chunk and stops a paste after refusal', async () => { + ptyOwnership.set(id, 'connection-1') + const paste = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 1) + provider.writeWithSettlement.mockReturnValueOnce(writeRefused('transport_queue_full')) + await expect(write(paste)).resolves.toBe(false) + provider.writeWithSettlement + .mockReturnValueOnce(WRITE_ACCEPTED) + .mockReturnValueOnce(writeRefused('transport_queue_full')) + // An accepted prefix makes the refusal partial, matching the paired host's verdict. + await expect(write(paste)).rejects.toThrow('acknowledgment unavailable: partial_write') + expect(provider.writeWithSettlement).toHaveBeenCalledTimes(3) + expect(provider.write).not.toHaveBeenCalled() + }) +}) + +describe('pane Escape/Ctrl+C keep the plain accepted write', () => { + it('writes local input without waiting for settlement and refuses SSH', () => { + ptyOwnership.set(id, null) + expect(paneWrite('\x1b')).toBe(true) + expect(provider.write).toHaveBeenCalledExactlyOnceWith(id, '\x1b') + ptyOwnership.set(id, 'connection-1') + expect(paneWrite('\x03')).toBe(false) + expect(provider.writeWithSettlement).not.toHaveBeenCalled() + }) + + it('asks a pane awaiting daemon recovery to remount on either route', () => { + ptyOwnership.set(id, null) + provider.write.mockImplementationOnce(() => { + throw new PtyWriteUnavailableError('awaiting recovery') + }) + expect(paneWrite('\x1b')).toBe(false) + provider.writeWithSettlement.mockReturnValueOnce(writeRefused('endpoint_awaiting_recovery')) + expect(write('1')).toBe(false) + expect(send).toHaveBeenCalledTimes(2) + expect(send).toHaveBeenCalledWith('pty:writeUnavailable', { id }) + }) +}) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index 2f8ea72cd9b0..14ecb14c0c1a 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -11,6 +11,7 @@ import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import { interactiveOutputCharsByPty, lastInputAtByPty } from '../delivery/visibility-state' +import { isSettledWrite, type WriteSettlement } from '../../../../shared/pty-write-settlement' export function isMainWindowPtyIpcEvent( event: IpcMainEvent | IpcMainInvokeEvent, @@ -26,7 +27,13 @@ export function isMainWindowPtyIpcEvent( ) } -export type PtyWritePayload = { id: string; data: string; inputKind: TerminalInputKind } +export type PtyWritePayload = { + id: string + data: string + inputKind: TerminalInputKind + /** Accepted-write callers only: wait for the provider's settlement, on any provider. */ + requireWriteSettlement?: true +} export type PtyViewportClaimPayload = { id: string; cols: number; rows: number } export function createPtyWriteInput(deps: { @@ -41,9 +48,8 @@ export function createPtyWriteInput(deps: { } { const { mainWindow, runtime } = deps - const reportUnavailablePtyWrite = (id: string, error: unknown): void => { + const sendPtyWriteUnavailable = (id: string): void => { if ( - !isPtyWriteUnavailableError(error) || !mainWindow || mainWindow.isDestroyed() || (typeof mainWindow.webContents.isDestroyed === 'function' && @@ -54,49 +60,89 @@ export function createPtyWriteInput(deps: { mainWindow.webContents.send('pty:writeUnavailable', { id }) } + const reportUnavailablePtyWrite = (id: string, error: unknown): void => { + if (isPtyWriteUnavailableError(error)) { + sendPtyWriteUnavailable(id) + } + } + const writePtyProviderInputWithinLimit = ( provider: IPtyProvider, id: string, - data: string + data: string, + verify = false ): boolean | Promise => { const chunks = iterateTerminalInputChunks(data) const first = chunks.next() if (first.done) { - provider.write(id, data) - return true + return writeChunk(provider, id, data, verify) } const second = chunks.next() if (second.done) { - provider.write(id, first.value) + return writeChunk(provider, id, first.value, verify) + } + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value, verify) + } + + const acceptedSettlement = (id: string, settlement: WriteSettlement): boolean => { + if (settlement.outcome === 'unverifiable') { + // A lost acknowledgment must not trigger a fallback write of the same bytes. + throw new Error(`PTY write acknowledgment unavailable: ${settlement.reason}`) + } + if (settlement.outcome === 'refused' && settlement.reason === 'endpoint_awaiting_recovery') { + // Settlement reports what a plain write would have thrown; the pane still needs to remount. + sendPtyWriteUnavailable(id) + } + return settlement.outcome === 'accepted' + } + + const writeChunk = ( + provider: IPtyProvider, + id: string, + data: string, + verify: boolean + ): boolean | Promise => { + if (!verify) { + provider.write(id, data) return true } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + const settlement = provider.writeWithSettlement(id, data) + return isSettledWrite(settlement) + ? acceptedSettlement(id, settlement) + : settlement.then((settled) => acceptedSettlement(id, settled)) + } + + const failedWrite = (id: string, error: unknown, verify: boolean): false => { + reportUnavailablePtyWrite(id, error) + if (verify && !isPtyWriteUnavailableError(error)) { + throw error + } + return false } const writePtyProviderInput = ( provider: IPtyProvider, id: string, - data: string + data: string, + verify = false ): boolean | Promise => { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data, verify) } return tooLarge .then((result) => { if (result) { return false } - return writePtyProviderInputWithinLimit(provider, id, data) + return writePtyProviderInputWithinLimit(provider, id, data, verify) }) .catch((error) => { - reportUnavailablePtyWrite(id, error) - return false + return failedWrite(id, error, verify) }) } catch (error) { - reportUnavailablePtyWrite(id, error) - return false + return failedWrite(id, error, verify) } } @@ -105,13 +151,23 @@ export function createPtyWriteInput(deps: { id: string, chunks: Iterator, firstChunk: string, - secondChunk: string + secondChunk: string, + verify: boolean ): Promise => { try { let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { done: false, value: secondChunk } + let wroteChunk = false while (!chunk.done) { - provider.write(id, chunk.value) + const accepted = writeChunk(provider, id, chunk.value, verify) + if (!(typeof accepted === 'boolean' ? accepted : await accepted)) { + if (wroteChunk) { + // An accepted prefix is already in the PTY, so this is not a clean refusal. + throw new Error('PTY write acknowledgment unavailable: partial_write') + } + return false + } + wroteChunk = true if (!nextChunk.done) { // setImmediate, not setTimeout(0): the yield exists to let abort/data callbacks run // between chunks, and a clamped timer tick per 16 KiB is pure latency. @@ -122,8 +178,7 @@ export function createPtyWriteInput(deps: { } return true } catch (error) { - reportUnavailablePtyWrite(id, error) - return false + return failedWrite(id, error, verify) } } @@ -172,10 +227,25 @@ export function createPtyWriteInput(deps: { } } + const writePtyInputSettled = (args: PtyWritePayload): boolean | Promise => { + if (!ptyOwnership.has(args.id)) { + return false + } + const provider = tryGetProviderForPty(args.id) + if (!provider?.hasPty?.(args.id)) { + return false + } + noteRendererPtyInput(args) + return writePtyProviderInput(provider, args.id, args.data, true) + } + const writePtyInputAccepted = (args: PtyWritePayload): boolean | Promise => { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } + if (args.requireWriteSettlement === true) { + return writePtyInputSettled(args) + } // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. if (ptyOwnership.get(args.id) !== null) { return false diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 6eb3e7879f84..82368a663a59 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -106,6 +106,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string inputKind: TerminalInputKind + requireWriteSettlement?: true } ): Promise { const pty = this.getLivePtyForHandle(handle) @@ -118,11 +119,20 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso throw new Error('invalid_terminal_send') } await assertTerminalInputWithinLimitWithYield(action.text) - await this.writeTerminalAction(pty.pty.ptyId, action, payload, options) + const writeSettlement = await this.writeTerminalAction( + pty.pty.ptyId, + action, + payload, + options + ) return { handle, - accepted: true, - bytesWritten: Buffer.byteLength(payload, 'utf8') + accepted: !writeSettlement || writeSettlement.outcome === 'accepted', + ...(writeSettlement ? { writeSettlement } : {}), + bytesWritten: + !writeSettlement || writeSettlement.outcome === 'accepted' + ? Buffer.byteLength(payload, 'utf8') + : 0 } } @@ -143,12 +153,16 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso throw new Error('terminal_not_writable') } - await this.writeTerminalAction(leaf.ptyId, action, payload, options) + const writeSettlement = await this.writeTerminalAction(leaf.ptyId, action, payload, options) return { handle, - accepted: true, - bytesWritten: Buffer.byteLength(payload, 'utf8') + accepted: !writeSettlement || writeSettlement.outcome === 'accepted', + ...(writeSettlement ? { writeSettlement } : {}), + bytesWritten: + !writeSettlement || writeSettlement.outcome === 'accepted' + ? Buffer.byteLength(payload, 'utf8') + : 0 } } diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index 1f66feb46815..ac21f8de73ed 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -10,6 +10,7 @@ import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' +import type { WriteSettlement } from '../../shared/pty-write-settlement' import { getRegisteredSshState } from '../ssh/ssh-target-registry' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' @@ -164,7 +165,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, options: RuntimeTerminalWriteOptions - ): Promise { + ): Promise { return this.terminalWriter.writeAction(ptyId, action, payload, options) } @@ -172,7 +173,7 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O ptyId: string, text: string, options: RuntimeTerminalWriteOptions - ): Promise { + ): Promise { return this.terminalWriter.writeChunks(ptyId, text, options) } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 18cdbf79c5ee..c872401f04ba 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -45,6 +45,7 @@ import type { PtyIncarnationHandleRecord } from './orca-runtime-core' import { MailPointerRepointScheduler } from './orchestration/mail-pointer-repoint-scheduler' import { RuntimeTerminalWaiterRegistry } from './runtime-terminal-waiter-registry' import { RuntimeTerminalWriter } from './runtime-terminal-writer' +import { writeRefused } from '../../shared/pty-write-settlement' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' import { TerminalIntentionalStops } from './terminal-intentional-stops' import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' @@ -250,9 +251,7 @@ export class OrcaRuntimeWithRuntimeId { this.intentionalPtyStops.noteSpawnCommit(commit.id) } - // Why: coalesces title/status-driven session.tabs emits so spinner churn - // doesn't fan out (and per-client JSON.stringify) a snapshot several times a - // second. Emit reads the latest snapshot, so only the freshest version ships. + // Coalesce title/status notifications and emit the latest session snapshot. protected readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = createMobileSessionTabsNotifyCoalescer((worktreeId) => this.flushScheduledMobileSessionTabsChanged(worktreeId) @@ -264,10 +263,7 @@ export class OrcaRuntimeWithRuntimeId { (worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId) ) - // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) - // each await a full host reveal; only one terminal can be focused, so latest-wins - // single-flight bounds host work. Does not replace cheaper activation or - // reconnect-scan bounding for sequential soft freezes. + // Concurrent focus requests share one host reveal; the latest pane wins. protected readonly terminalFocusNavigationCoalescer = new TerminalFocusNavigationCoalescer() @@ -338,7 +334,10 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWriter = new RuntimeTerminalWriter( (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), - (ptyId) => this.getPtyAgent(ptyId) + (ptyId) => this.getPtyAgent(ptyId), + (ptyId, data, inputKind) => + this.ptyController?.writeWithSettlement?.(ptyId, data, inputKind) ?? + writeRefused('provider_cannot_settle') ) // Why one source: every tui-idle site must read the same evidence, or they rank one pane differently. diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index ca7ac0aeb759..8309d8c15c12 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -237,6 +237,7 @@ export const TERMINAL_SEND_METHODS = [ signal, // Why: a wire write carries no provenance beyond a client's own query reply. inputKind: params.inputKind === 'query-reply' ? 'query-reply' : 'driving', + ...(params.requireWriteSettlement ? { requireWriteSettlement: true as const } : {}), ...(reserveWrite ? { reserveWrite } : {}), ...(params.inputKind !== 'query-reply' && mobileFloorClientId ? { afterWrite: () => commitMobileInputFloorClaim(mobileFloorClaim) } diff --git a/src/main/runtime/runtime-terminal-writer.test.ts b/src/main/runtime/runtime-terminal-writer.test.ts new file mode 100644 index 000000000000..4fd79f8d9f33 --- /dev/null +++ b/src/main/runtime/runtime-terminal-writer.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RuntimeTerminalWriter } from './runtime-terminal-writer' +import { + WRITE_ACCEPTED, + writeRefused, + writeUnverifiable, + type WriteSettlement +} from '../../shared/pty-write-settlement' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' + +afterEach(() => vi.useRealTimers()) + +describe('requested terminal write settlement', () => { + it('waits for complete body settlement before suffix and host acknowledgment', async () => { + vi.useFakeTimers() + const raw = vi.fn((_id: string, _data: string) => true) + const bytes: string[] = [] + let finish: (result: WriteSettlement) => void = () => { + throw new Error('write not started') + } + const settled = vi.fn((_id: string, data: string) => { + bytes.push(data) + return bytes.length === 1 + ? new Promise((resolve) => { + finish = resolve + }) + : WRITE_ACCEPTED + }) + const writer = new RuntimeTerminalWriter( + raw, + () => 'linux', + () => null, + settled + ) + const text = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) + const pending = writer.writeAction('pty', { text, enter: true }, `${text}\r`, { + inputKind: 'driving', + requireWriteSettlement: true + }) + await vi.advanceTimersByTimeAsync(2000) + expect(bytes).toHaveLength(1) + finish(WRITE_ACCEPTED) + await vi.advanceTimersByTimeAsync(2000) + await expect(pending).resolves.toEqual(WRITE_ACCEPTED) + expect(bytes.join('')).toBe(`${text}\r`) + expect(bytes.at(-1)).toBe('\r') + expect(raw).not.toHaveBeenCalled() + }) + + it.each([ + writeRefused('endpoint_disconnected'), + writeUnverifiable('transport_settlement_lost', true) + ])('preserves first-write $outcome and never sends a suffix', async (verdict) => { + const raw = vi.fn((_id: string, _data: string) => true) + const settled = vi.fn(() => verdict) + const writer = new RuntimeTerminalWriter( + raw, + () => 'linux', + () => null, + settled + ) + await expect( + writer.writeAction('pty', { text: 'body', enter: true }, 'body\r', { + inputKind: 'driving', + requireWriteSettlement: true + }) + ).resolves.toEqual(verdict) + expect(settled).toHaveBeenCalledOnce() + expect(raw).not.toHaveBeenCalled() + }) + + it('keeps a later refusal unknown after an acknowledged prefix', async () => { + const settled = vi + .fn() + .mockReturnValueOnce(WRITE_ACCEPTED) + .mockReturnValueOnce(writeRefused('transport_queue_full')) + const writer = new RuntimeTerminalWriter( + () => true, + () => 'linux', + () => null, + settled + ) + const text = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES + 1) + await expect( + writer.writeAction('pty', { text, enter: true }, `${text}\r`, { + inputKind: 'driving', + requireWriteSettlement: true + }) + ).resolves.toMatchObject({ + outcome: 'unverifiable', + reason: 'partial_write', + bytesHandedToTransport: true + }) + expect(settled).toHaveBeenCalledTimes(2) + }) + + it.each(['afterWrite', 'beforeSuffix'] as const)( + 'keeps a %s failure unknown after handing off bytes', + async (failure) => { + vi.useFakeTimers() + const settled = vi.fn(() => WRITE_ACCEPTED) + let calls = 0 + const writer = new RuntimeTerminalWriter( + () => true, + () => 'linux', + () => null, + settled + ) + const pending = writer.writeAction('pty', { text: 'body', enter: true }, 'body\r', { + inputKind: 'driving', + requireWriteSettlement: true, + beforeWrite: () => { + if (++calls > 1 && failure === 'beforeSuffix') { + throw new Error('terminal_not_writable') + } + }, + afterWrite: () => { + if (failure === 'afterWrite') { + throw new Error('bookkeeping failed') + } + } + }) + await vi.advanceTimersByTimeAsync(1000) + await expect(pending).resolves.toMatchObject({ + outcome: 'unverifiable', + bytesHandedToTransport: true + }) + expect(settled).toHaveBeenCalledOnce() + } + ) + + it('refuses a requested settlement before raw writes when the provider cannot settle', async () => { + const raw = vi.fn((_id: string, _data: string) => true) + const writer = new RuntimeTerminalWriter(raw) + await expect( + writer.writeAction('pty', { text: 'choice' }, 'choice', { + inputKind: 'driving', + requireWriteSettlement: true + }) + ).resolves.toEqual(writeRefused('provider_cannot_settle')) + expect(raw).not.toHaveBeenCalled() + }) + + it('preserves legacy raw body and Enter requests without adding provider waits', async () => { + vi.useFakeTimers() + const raw = vi.fn((_id: string, _data: string) => true) + const settled = vi.fn(() => WRITE_ACCEPTED) + const writer = new RuntimeTerminalWriter( + raw, + () => 'linux', + () => null, + settled + ) + const pending = writer.writeAction('pty', { text: 'body', enter: true }, 'body\r', { + inputKind: 'driving' + }) + await vi.advanceTimersByTimeAsync(1000) + await expect(pending).resolves.toBeUndefined() + expect(raw.mock.calls.map((call) => call[1])).toEqual(['body', '\r']) + expect(settled).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 8c308d22b281..ddc523424043 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -2,6 +2,12 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt import type { TerminalAgent } from '../../shared/terminal-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' import type { TerminalInputKind } from '../../shared/terminal-input-kind' +import { + WRITE_ACCEPTED, + writeRefused, + writeUnverifiable, + type WriteSettlement +} from '../../shared/pty-write-settlement' export type RuntimeTerminalWriteOptions = { inputKind: TerminalInputKind @@ -10,6 +16,7 @@ export type RuntimeTerminalWriteOptions = { reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise suffixFailureError?: string + requireWriteSettlement?: true } export class RuntimeTerminalWriter { @@ -17,7 +24,12 @@ export class RuntimeTerminalWriter { private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, - private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null + private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null, + private readonly writeWithSettlement?: ( + ptyId: string, + data: string, + inputKind: TerminalInputKind + ) => WriteSettlement | Promise ) {} async writeAction( @@ -25,13 +37,45 @@ export class RuntimeTerminalWriter { action: { text?: string; enter?: boolean; interrupt?: boolean }, payload: string, options: RuntimeTerminalWriteOptions - ): Promise { + ): Promise { + let acknowledgedPrefix = false + const guardedOptions = options.requireWriteSettlement + ? { + ...options, + afterWrite: async (id: string): Promise => { + acknowledgedPrefix = true + await options.afterWrite?.(id) + } + } + : options + try { + const settlement = await this.writeActionWithinLimit(ptyId, action, payload, guardedOptions) + return acknowledgedPrefix && settlement?.outcome === 'refused' + ? writeUnverifiable('partial_write', true) + : settlement + } catch (error) { + if (acknowledgedPrefix) { + return writeUnverifiable('partial_write', true) + } + throw error + } + } + + private async writeActionWithinLimit( + ptyId: string, + action: { text?: string; enter?: boolean; interrupt?: boolean }, + payload: string, + options: RuntimeTerminalWriteOptions + ): Promise { // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. const text = typeof action.text === 'string' ? action.text : '' const hasSuffix = action.enter || action.interrupt if (text) { - await this.writeChunks(ptyId, text, options) + const settlement = await this.writeChunks(ptyId, text, options) + if (settlement && settlement.outcome !== 'accepted') { + return settlement + } } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') @@ -56,35 +100,39 @@ export class RuntimeTerminalWriter { throw error } options.reserveWrite?.(ptyId) - if (!this.write(ptyId, suffix, options.inputKind)) { - throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + const settlement = await this.writeInput(ptyId, suffix, options) + if (settlement && settlement.outcome !== 'accepted') { + return settlement } await options.afterWrite?.(ptyId) - return + return settlement } if (text) { - return + return options.requireWriteSettlement ? WRITE_ACCEPTED : undefined } await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, payload, options.inputKind)) { - throw new Error('terminal_not_writable') + const settlement = await this.writeInput(ptyId, payload, options) + if (settlement && settlement.outcome !== 'accepted') { + return settlement } await options.afterWrite?.(ptyId) + return settlement } async writeChunks( ptyId: string, text: string, options: RuntimeTerminalWriteOptions - ): Promise { + ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() while (!chunk.done) { await options.beforeWrite?.(ptyId) options.reserveWrite?.(ptyId) - if (!this.write(ptyId, chunk.value, options.inputKind)) { - throw new Error('terminal_not_writable') + const settlement = await this.writeInput(ptyId, chunk.value, options) + if (settlement && settlement.outcome !== 'accepted') { + return settlement } await options.afterWrite?.(ptyId) chunk = chunks.next() @@ -92,6 +140,28 @@ export class RuntimeTerminalWriter { await yieldBetweenTerminalInputChunks() } } + return options.requireWriteSettlement ? WRITE_ACCEPTED : undefined + } + + private async writeInput( + ptyId: string, + data: string, + options: RuntimeTerminalWriteOptions + ): Promise { + if (!options.requireWriteSettlement) { + if (!this.write(ptyId, data, options.inputKind)) { + throw new Error(options.suffixFailureError ?? 'terminal_not_writable') + } + return undefined + } + if (!this.writeWithSettlement) { + return writeRefused('provider_cannot_settle') + } + try { + return await this.writeWithSettlement(ptyId, data, options.inputKind) + } catch { + return writeUnverifiable('provider_threw_after_handoff', true) + } } } diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 4cfb5572776c..8a7604b4ac25 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -78,7 +78,13 @@ export type PtyApi = { shellReadyArmed?: boolean }> write: (id: string, data: string, inputKind: TerminalInputKind) => void - writeAccepted: (id: string, data: string, inputKind: TerminalInputKind) => Promise + /** `requireWriteSettlement` waits for the provider's acknowledgment on any provider. */ + writeAccepted: ( + id: string, + data: string, + inputKind: TerminalInputKind, + options?: { requireWriteSettlement?: true } + ) => Promise onWriteUnavailable?: (callback: (payload: { id: string }) => void) => () => void resize: (id: string, cols: number, rows: number) => void claimViewport: (id: string, cols: number, rows: number) => void diff --git a/src/preload/api/pty-bridge-session-control.ts b/src/preload/api/pty-bridge-session-control.ts index 1f8639c58be0..e407163e5f6f 100644 --- a/src/preload/api/pty-bridge-session-control.ts +++ b/src/preload/api/pty-bridge-session-control.ts @@ -75,8 +75,13 @@ export const ptySessionControlApi = { write: (id: string, data: string, inputKind: TerminalInputKind): void => { ipcRenderer.send('pty:write', { id, data, inputKind }) }, - writeAccepted: (id: string, data: string, inputKind: TerminalInputKind): Promise => - ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind }), + writeAccepted: ( + id: string, + data: string, + inputKind: TerminalInputKind, + options?: { requireWriteSettlement?: true } + ): Promise => + ipcRenderer.invoke('pty:writeAccepted', { id, data, inputKind, ...options }), onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => callback(payload) diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index 8e50bce5e9f2..54b2285d618d 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -131,6 +131,30 @@ describe('NativeChatApprovalCard', () => { outside.remove() }) + it.each([ + ['this pane', true], + ['another surface', false] + ] as const)('with a text field focused in %s, takes focus: %s', (_where, takes) => { + const pane = document.createElement('div') + pane.setAttribute('data-native-chat-root', 'true') + const draft = document.createElement('textarea') + ;(takes ? pane : document.body).appendChild(draft) + document.body.appendChild(pane) + draft.focus() + render( + {}} + shouldFocus + />, + { container: pane } + ) + const card = screen.getByRole('group', { name: 'Allow command?' }) + expect(document.activeElement).toBe(takes ? card : draft) + pane.remove() + draft.remove() + }) + it('keeps all oversized provider context in one bounded scroller above the actions', () => { const description = `Read access outside the workspace ${'description '.repeat(400)}` const decisionReason = `The path is outside the allowed root. ${'reason '.repeat(400)}` diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 220025206102..00f883c99439 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import { ShieldQuestion, X } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -10,6 +10,8 @@ import { } from '../../../../shared/agent-session-approval-subject' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import type { ChatApproval } from './native-chat-interactive-prompt' +import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse' +import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus' export type NativeChatApprovalCardProps = { approval: ChatApproval @@ -17,6 +19,10 @@ export type NativeChatApprovalCardProps = { onChoose: (option: string) => void /** Cancel the active provider turn while this card owns the composer region. */ onCancel?: () => void + /** Without `onCancel`: fold the card to a strip and give the input back, writing nothing. */ + onCollapse?: () => void + /** A choice is being delivered: the options wait for its answer. */ + isSubmitting?: boolean shouldFocus?: boolean /** A plan body renders as markdown; these make its file paths clickable. */ onLinkClick?: CommentMarkdownLinkClickHandler @@ -32,6 +38,8 @@ export function NativeChatApprovalCard({ approval, onChoose, onCancel, + onCollapse, + isSubmitting = false, shouldFocus = false, onLinkClick, allowFileUriLinks = false @@ -47,11 +55,8 @@ export function NativeChatApprovalCard({ approval.subject || approval.detail ) - useEffect(() => { - if (shouldFocus) { - cardRef.current?.focus() - } - }, [shouldFocus]) + useNativeChatPromptCardFocus(cardRef, shouldFocus) + const escape = onCancel ?? (isSubmitting ? undefined : onCollapse) return (
@@ -63,10 +68,10 @@ export function NativeChatApprovalCard({ aria-label={approval.title} tabIndex={-1} onKeyDown={(event) => { - if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) { + if (event.key === 'Escape' && !event.nativeEvent.isComposing && escape) { event.preventDefault() event.stopPropagation() - onCancel() + escape() } }} className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" @@ -87,6 +92,12 @@ export function NativeChatApprovalCard({ > + ) : onCollapse ? ( + ) : null}
{hasContext ? ( @@ -170,7 +181,7 @@ export function NativeChatApprovalCard({ + + + {label} + {expanded ? : null} + + + ) +} + +/** A collapsed prompt above the usable composer; expanding gives it the input again. */ +export function NativeChatPromptStrip({ + card, + onExpand +}: { + card: NonNullable + onExpand: () => void +}): React.JSX.Element { + const title = + card.kind === 'approval' ? card.approval.title : (card.prompt.questions[0]?.question ?? '') + return ( +
+
+
+

{title}

+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index c7d50ebc8160..dbb90df8b9f0 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -1,19 +1,27 @@ import { ImeInput } from '@/lib/ime-text-field' -import { useState, type RefObject } from 'react' +import { useRef, useState, type RefObject } from 'react' import { Check, Pencil, X } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-prompt' +import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse' +import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus' +import { isEditableTarget } from '@/lib/editable-target' export type NativeChatQuestionCardProps = { prompt: AskPrompt /** Whether the snapshotted answer is still being delivered to the agent. */ isSubmitting?: boolean + isCancelling?: boolean /** Deliver the chosen answer (per-question option indices + free text). */ onAnswer: (selections: AskAnswerSelection[]) => void allowOther?: boolean | readonly boolean[] /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void + /** Fold the card to a strip and give the input back, writing nothing; Escape does too. */ + onCollapse?: () => void + /** Take focus when the card takes the input region. */ + shouldFocus?: boolean /** Exposes the free-text row so pane-level Paste can target it while the * card replaces the composer. */ answerInputRef?: RefObject @@ -34,11 +42,16 @@ const TYPED_ANSWER = -1 export function NativeChatQuestionCard({ prompt, isSubmitting = false, + isCancelling = false, onAnswer, allowOther = true, onCancel, + onCollapse, + shouldFocus = false, answerInputRef }: NativeChatQuestionCardProps): React.JSX.Element { + const cardRef = useRef(null) + useNativeChatPromptCardFocus(cardRef, shouldFocus) const [index, setIndex] = useState(0) // Keep option identity by index: labels are display text and are not guaranteed // unique, while Claude's selector commits the numbered row (STA-1860). @@ -158,7 +171,28 @@ export function NativeChatQuestionCard({ // Part of the composer: docked in the bottom input region, matching the // composer's width and padding, rendered as the "ask" dialog card directly // above the text input. Its free-text row is the answer input. -
+
{ + // Why not from a text field: Escape there is editing, and collapsing would hide the draft. + if ( + event.key === 'Escape' && + !event.nativeEvent.isComposing && + onCollapse && + !isSubmitting && + !isEditableTarget(event.target) + ) { + event.preventDefault() + event.stopPropagation() + onCollapse() + } + }} + >
{total > 1 ? (
@@ -190,16 +224,24 @@ export function NativeChatQuestionCard({ ) : null}
-
+

{q.question}

+ {onCollapse ? ( + + ) : null}