From c893048aebccfab64730ae2d8ba9f917d160e3ea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:14:04 -0700 Subject: [PATCH 001/394] feat(native-chat): show which agent a chat message is from, and open it (#25888) * feat(native-chat): show which agent a chat message is from A message another agent sends into a native chat (today the orchestration mail notice) looked exactly like the person's own: a right-hand bubble and a queued card identical to theirs. The sender was recorded only on the queue row, so a message sent to an idle chat, or a card once it was sent, had no sender at all. The sender now lives on the message itself (`from` on the message body): - The send path carries it on both deliveries; an idle chat records it too, and it survives the queue, /clear's carry, restart and history. - No fingerprint covers it: one shared payload projection leaves it out at every fingerprint site, so stored digests are unchanged and the provider's echo still folds into the same bubble. The provider never receives it. - A client cannot set it: its send params stay strict. - One reader normalizes it where it is read (journal load, queued row, client projection); a newer message kind keeps the sender an agent's. - Each sender keeps a name snapshot from names Orca controls: a chat's conversation name or agent label, a terminal's own tab title or agent, a federated sender's task name. Never an agent-set terminal title. - The separate queued-row sender column and its plumbing are removed; it never shipped. Desktop shows such a message as a left-aligned "Message from " row; the name opens the sender: its chat through the existing open-chat flow (after a host lookup that follows /clear and dispatch addresses), or its terminal through the terminal-link path, with the existing feedback when it is gone. A queued card shows a plain "From " line. Mobile shows the same attribution, not yet clickable. * fix(native-chat): name and open an agent's message by what Orca shows for it Review fixes for showing who an agent message is from. - Names: a sender is named by its dispatch's task (local workers too, not only federated ones), else a chat by the label its tab shows, else a terminal by its tab's stored title, else its agent. The conversation name the snapshot read before has no writer, so every chat read " Chat". - One sender builder (agentMessageSender) and a public runtime naming object (orchestrationSenderNames) any send site can use; the terminal naming moves out of the unrelated runtime layer into its own module. - Opening a terminal sender always asks the chat's host: a terminal handle is issued per run, so the host finds an earlier run's terminal again through the pane its mail was sent from. The pane key never leaves the host. - Feedback follows what the host found: a gone chat or a gone pane by the kind it lost, "pane no longer available" only when the terminal answer proves it, and the existing "couldn't reach" / "needs a newer Orca" words otherwise. - Another agent's message is not offered rewind (it would put the agent's text in the person's composer), and a Codex rewind keeps the sender on the messages it retains. - The queued card and mobile name every sender, two that share a name included, and count in "+N" only those left out; the transcript row separates names with ", ". - The host no longer normalizes the sender when loading the journal or the queue: nothing on the host reads it, and clients read it through the one reader at their projections. * fix(native-chat): name a worker's final report by its task; clearer open-sender failures - A worker's own worker_done settles its dispatch before the mail lane names who the report is from, so a terminal worker's completion read "Codex". A local worker is now named by the dispatch it holds, else the one it last held (handles are per run, so that is this run's). The coordinator mail rig now attests the worker's process, so its report is accepted and settles as it does in production. - Opening a sender: a version block or an unknown answer says "Update Orca to open it.", since either side may be the older one; "This needs a newer Orca on the computer running this chat" stays only for a host that predates the lookup. "Orca couldn't reach the agent." is followed by "Try again.", each sentence translated on its own. No new copy. * fix(native-chat): name a worker only by work it holds or just reported A worker with no active dispatch was named by the newest dispatch ever assigned to its terminal, at any status and any age: one that failed before it ran, or a task finished long ago that its later, unrelated mail kept showing. Now a worker is named by its active dispatch, else by the dispatch its own worker_done in the announced mail names (only if that dispatch is its own), else by its tab or agent as any terminal is. * fix(native-chat): restore a rewound message's sender through its typed body; mobile card fixtures name their attribution - The rewind's held-sender restore lives with the retained rows and reads both bodies through the journal's message-body check, instead of a shared helper taking any object (anti-slop: no-object-parameters). - Mobile queued-card test fixtures carry the attribution field the card gained. * fix(native-chat): never call a sender gone without proof; the sender builder moves out of the mail lane - A federated sender (dispatch:) runs on another host, which the chat's host cannot open: its name is plain text, and the host's lookup answers 'not found' rather than 'gone' for a dispatch it does not run. Not seeing a worker is no evidence it stopped. - The lookup calls a chat gone only when the identity is proven lost; any other failure reaches the client as 'could not reach'. - A sender open that throws shows 'couldn't reach the agent' instead of an unhandled rejection. - agentMessageSender, the one sender builder, lives in its own module, so the runtime's naming no longer imports from the mail lane. --- .../session/MobileNativeChatMessage.test.ts | 25 ++ .../src/session/MobileNativeChatMessage.tsx | 19 +- .../MobileNativeChatQueuedMessages.test.tsx | 1 + .../MobileNativeChatQueuedMessages.tsx | 5 + .../mobile-agent-message-attribution.test.ts | 57 +++++ .../mobile-agent-message-attribution.ts | 22 ++ .../mobile-native-chat-message-styles.ts | 9 + ...le-structured-queued-message-cards.test.ts | 3 +- .../mobile-structured-queued-message-cards.ts | 7 +- ...se-mobile-native-chat-queued-slot.test.tsx | 3 +- ...e-structured-agent-session-queued.test.tsx | 3 +- .../journal-queued-messages.ts | 2 - .../journal-reducer.test.ts | 30 +++ .../agent-session-journal/journal-reducer.ts | 8 +- .../journal-row-builders.ts | 2 +- .../journal-submission-queued-link.test.ts | 6 +- .../journal-unsent-send-hold.test.ts | 20 +- .../journal-unsent-send-hold.ts | 16 +- ...queued-message-bookkeeping-failure.test.ts | 6 +- .../queued-message-delivered-echo.test.ts | 9 +- .../queued-message-delivered-echo.ts | 8 +- .../queued-message-pause.test.ts | 5 +- .../queued-message-schema.ts | 4 +- .../queued-message-store.test.ts | 58 +++-- .../queued-message-stored-row.ts | 18 +- .../queued-message-table.ts | 17 +- ...structured-agent-session-host-mutations.ts | 8 +- ...structured-agent-session-mutation-plans.ts | 44 ++-- ...session-queued-message-rig.test-fixture.ts | 15 +- ...ured-agent-session-queued-messages.test.ts | 30 +-- ...tructured-agent-session-queued-messages.ts | 30 +-- ...ructured-agent-session-queued-mutations.ts | 9 +- .../structured-agent-session-queued-send.ts | 3 - ...ent-session-restore-without-import.test.ts | 3 +- ...red-agent-session-unsent-send-hold.test.ts | 30 ++- ...ructured-rewind-retained-host-rows.test.ts | 43 +++- .../structured-rewind-retained-host-rows.ts | 28 ++- .../orca-runtime-stop-requested-pty-ids.ts | 12 + .../orchestration/agent-facing-parity.test.ts | 1 + .../orchestration/agent-message-sender.ts | 51 ++++ .../orchestration-party-location.test.ts | 159 ++++++++++++ .../orchestration-party-location.ts | 106 ++++++++ .../orchestration/orchestration-party.ts | 18 +- .../orchestration-sender-name.ts | 97 ++++++++ .../send-agent-turn-host.test.ts | 43 +++- .../orchestration/send-agent-turn.test.ts | 33 ++- .../runtime/orchestration/send-agent-turn.ts | 15 +- .../structured-mail-source.test.ts | 41 +++- .../orchestration/structured-mail-source.ts | 49 +++- ...tructured-mailbox-pointer-delivery.test.ts | 6 +- .../structured-mailbox-pointer-delivery.ts | 23 +- .../structured-mailbox-pointer-host.test.ts | 7 +- .../structured-mailbox-pointer-host.ts | 1 - .../agent-launch-structured-prompt.test.ts | 2 +- .../methods/agent-launch-structured-prompt.ts | 3 +- .../rpc/methods/orchestration/caller-show.ts | 24 +- .../orchestration-party-addressing.test.ts | 47 +++- .../rpc/orchestration-session-caller.test.ts | 15 +- ...runtime-orchestration-sender-names.test.ts | 207 ++++++++++++++++ .../runtime-orchestration-sender-names.ts | 83 +++++++ ...ctured-chat-coordinator-mail-queue.test.ts | 21 +- ...-chat-coordinator-mail-rig.test-fixture.ts | 9 +- .../NativeChatAgentMessageRow.test.tsx | 162 ++++++++++++ .../NativeChatAgentMessageSenders.tsx | 61 +++++ .../native-chat/NativeChatMessageRow.tsx | 24 +- .../NativeChatQueuedMessageCard.tsx | 6 + .../native-chat-agent-message-sender-label.ts | 25 ++ .../native-chat-rewind-eligibility.ts | 4 +- ...uctured-agent-session-queued-cards.test.ts | 22 ++ .../structured-agent-session-queued-cards.ts | 10 +- .../use-native-chat-rewind.test.tsx | 17 ++ .../terminal-pane/stale-agent-row.ts | 27 +- .../terminal-pane/terminal-handle-links.ts | 2 +- src/renderer/src/i18n/locales/en.json | 7 +- src/renderer/src/i18n/locales/es.json | 7 +- src/renderer/src/i18n/locales/fr.json | 7 +- src/renderer/src/i18n/locales/ja.json | 7 +- src/renderer/src/i18n/locales/ko.json | 7 +- src/renderer/src/i18n/locales/zh.json | 7 +- .../activate-ai-vault-structured-session.ts | 17 +- .../src/lib/open-agent-message-sender.test.ts | 232 ++++++++++++++++++ .../src/lib/open-agent-message-sender.ts | 183 ++++++++++++++ src/shared/agent-session-journal-types.ts | 4 + .../agent-session-message-source.test.ts | 87 +++++++ src/shared/agent-session-message-source.ts | 98 +++++--- src/shared/native-chat-types.ts | 3 + src/shared/orchestration-caller-status.ts | 12 + .../rpc-contract/orchestration-params.ts | 7 + .../rpc-params-catalog.generated.ts | 2 + ...ed-agent-session-projection-sender.test.ts | 43 ++++ .../structured-agent-session-projection.ts | 6 +- ...ctured-agent-session-send-mutation.test.ts | 74 ++++++ .../structured-agent-session-send-mutation.ts | 28 ++- .../kept-card-downgrade.unit.test.ts | 3 +- 94 files changed, 2514 insertions(+), 366 deletions(-) create mode 100644 mobile/src/session/mobile-agent-message-attribution.test.ts create mode 100644 mobile/src/session/mobile-agent-message-attribution.ts create mode 100644 src/main/runtime/orchestration/agent-message-sender.ts create mode 100644 src/main/runtime/orchestration/orchestration-party-location.test.ts create mode 100644 src/main/runtime/orchestration/orchestration-party-location.ts create mode 100644 src/main/runtime/orchestration/orchestration-sender-name.ts create mode 100644 src/main/runtime/runtime-orchestration-sender-names.test.ts create mode 100644 src/main/runtime/runtime-orchestration-sender-names.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts create mode 100644 src/renderer/src/lib/open-agent-message-sender.test.ts create mode 100644 src/renderer/src/lib/open-agent-message-sender.ts create mode 100644 src/shared/agent-session-message-source.test.ts create mode 100644 src/shared/structured-agent-session-projection-sender.test.ts create mode 100644 src/shared/structured-agent-session-send-mutation.test.ts diff --git a/mobile/src/session/MobileNativeChatMessage.test.ts b/mobile/src/session/MobileNativeChatMessage.test.ts index 2a2489bbdbc2..97796a8a0e7f 100644 --- a/mobile/src/session/MobileNativeChatMessage.test.ts +++ b/mobile/src/session/MobileNativeChatMessage.test.ts @@ -7,6 +7,7 @@ import { projectStructuredItemToNativeChat } from '../../../src/shared/structure import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { AGENT_SESSION_HOST_STATUS_COPY } from '../../../src/shared/agent-session-host-status-rows' import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-native-chat-message-styles' vi.mock('react-native', async () => { const React = await import('react') @@ -119,6 +120,30 @@ describe('MobileNativeChatMessage', () => { } ) + it("names another agent's message and sets it apart from the person's bubble", () => { + const agentMessage: NativeChatMessage = { + ...userMessage([{ type: 'text', text: 'You have 1 orchestration message.' }]), + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + } + const tree = render(agentMessage) + expect(textIn(tree.root)[0]).toBe('Message from Coder') + // Left-aligned agent prose, not the person's inverted bubble. + expect(tree.root.findAll((node) => String(node.type) === 'MobileMarkdown')).toHaveLength(1) + const rows = tree.root.findAll( + (node) => String(node.type) === 'View' && Array.isArray(node.props.style) + ) + expect(rows.some((row) => row.props.style.includes(styles.rowUser))).toBe(false) + }) + it('preserves an ordinary assistant answer without interpreting its text as a host notice', () => { const tree = render(toolMessage([{ type: 'text', text: 'provider fallback' }])) expect(tree.root.find((node) => String(node.type) === 'MobileMarkdown').props.content).toBe( diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index ec0a6d48b310..1d61a8117ed4 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -20,6 +20,7 @@ import { ToolRun } from './MobileNativeChatToolRun' import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status' import { isRenderableImageUri } from './mobile-native-chat-image-preview' import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles' +import { agentMessageAttribution } from './mobile-agent-message-attribution' function Prose({ block, @@ -132,7 +133,9 @@ function MobileNativeChatMessageImpl({ reasoningExpanded?: boolean onToggleReasoning?: (key: string) => void }): React.JSX.Element { - const isUser = message.role === 'user' + // Another agent's message is set apart from the person's bubble, left-aligned and named. + const attribution = agentMessageAttribution('Message from', message.from) + const isUser = message.role === 'user' && attribution === null const isReasoning = message.role === 'reasoning' // Separate the agent's words from its tool activity: prose renders first, the // tool calls fold into a collapsible run beneath. The user's own messages get @@ -204,7 +207,19 @@ function MobileNativeChatMessageImpl({ {/* A turn with no user bubble carries its bar above its first row. */} {turnStatusAbove ? statusRow : null} - + + {attribution !== null ? ( + + {attribution} + + ) : null} {prose.map((block, index) => ( )} + {card.attribution ? ( + + {card.attribution} + + ) : null} {/* Two lines, not the desktop's one: the phone row has no hover title to read the rest. */} {card.text} diff --git a/mobile/src/session/mobile-agent-message-attribution.test.ts b/mobile/src/session/mobile-agent-message-attribution.test.ts new file mode 100644 index 000000000000..371758a525f1 --- /dev/null +++ b/mobile/src/session/mobile-agent-message-attribution.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { AgentMessageSource } from '../../../src/shared/agent-session-message-source' +import { agentMessageAttribution } from './mobile-agent-message-attribution' +import { mobileQueuedMessageCards } from './mobile-structured-queued-message-cards' + +function from(names: (string | null)[]): AgentMessageSource { + return { + kind: 'agent', + senders: names.map((name, index) => ({ + party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null }, + name + })), + orchestration: null + } +} + +describe('who another agent’s message is from, on mobile', () => { + it('names nothing on the person’s own message', () => { + expect(agentMessageAttribution('From', undefined)).toBeNull() + }) + + it('names each sender, an unnamed one as an agent, and counts the rest', () => { + expect(agentMessageAttribution('Message from', from(['Coder']))).toBe('Message from Coder') + expect(agentMessageAttribution('From', from([]))).toBe('From an agent') + expect(agentMessageAttribution('From', from(['A', 'B', 'C', 'D']))).toBe('From A, B, C +1') + }) + + it('keeps two senders that share a name two, and counts only the senders it leaves out', () => { + expect(agentMessageAttribution('From', from(['Codex', 'Codex']))).toBe('From Codex, Codex') + expect(agentMessageAttribution('From', from([null, null]))).toBe('From an agent, an agent') + expect(agentMessageAttribution('From', from(['Codex', 'Codex', 'Codex', 'Codex']))).toBe( + 'From Codex, Codex, Codex +1' + ) + }) + + it('puts the line on the queued card, read through the shared reader', () => { + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'You have 1 orchestration message.' }] + } + const cards = mobileQueuedMessageCards( + [ + { + messageId: 'agent', + position: 1, + state: 'waiting', + body: { ...body, from: from(['Coder']) } + }, + { messageId: 'person', position: 2, state: 'waiting', body } + ], + [], + { pendingPrompt: false } + ) + expect(cards.map((card) => card.attribution)).toEqual(['From Coder', null]) + }) +}) diff --git a/mobile/src/session/mobile-agent-message-attribution.ts b/mobile/src/session/mobile-agent-message-attribution.ts new file mode 100644 index 000000000000..8c1729c33375 --- /dev/null +++ b/mobile/src/session/mobile-agent-message-attribution.ts @@ -0,0 +1,22 @@ +// The plain line naming who sent another agent's message; not a link on mobile yet. + +import { + agentMessageSendersShown, + type AgentMessageSource +} from '../../../src/shared/agent-session-message-source' + +const UNNAMED_SENDER = 'an agent' + +/** Null for the person's own message. */ +export function agentMessageAttribution( + lead: 'From' | 'Message from', + source: AgentMessageSource | undefined +): string | null { + if (!source) { + return null + } + const { shown, more } = agentMessageSendersShown(source) + // One name per sender, repeats included: two senders that share a name are still two. + const names = shown.map((sender) => sender.name ?? UNNAMED_SENDER) + return `${lead} ${names.length > 0 ? names.join(', ') : UNNAMED_SENDER}${more > 0 ? ` +${more}` : ''}` +} diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index 2acad402176c..a18034fa505d 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -61,6 +61,15 @@ export const styles = StyleSheet.create({ // The common cap for an open reasoning block (about ten lines). maxHeight: 240 }, + agentMessage: { + paddingLeft: spacing.md, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle + }, + agentAttribution: { + color: colors.textMuted, + fontSize: typography.metaSize + }, toolRun: { marginTop: spacing.xs }, 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 987d9aeefd48..748dc785db9f 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -48,7 +48,8 @@ describe('mobileQueuedMessageCards', () => { state: 'waiting', paused: false, needsAttention: false, - caption: null + caption: null, + attribution: null }) }) diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 843292e44210..a254e2b65290 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -13,6 +13,8 @@ import { type AgentSessionQueuedMessage, type AgentSessionQueuePause } from '../../../src/shared/agent-session-wire' +import { readAgentMessageSource } from '../../../src/shared/agent-session-message-source' +import { agentMessageAttribution } from './mobile-agent-message-attribution' export type MobileQueuedMessageCard = { messageId: string @@ -24,6 +26,8 @@ export type MobileQueuedMessageCard = { needsAttention: boolean /** Status under the text; null for a card plainly waiting its turn, the paused queue's too. */ caption: string | null + /** "From " on another agent's card; null on the person's. */ + attribution: string | null } function queuedMessageBodyText(body: AgentSessionQueuedMessage['body']): string { @@ -136,7 +140,8 @@ export function mobileQueuedMessageCards( needsAttention: draft.state === 'returned' || (paused && draft.pausedReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED), - caption + caption, + attribution: agentMessageAttribution('From', readAgentMessageSource(draft.body.from)) }) if (draft.state === 'returned') { behindReturned = true diff --git a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx index 002a567548f6..d623953c0e9b 100644 --- a/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx +++ b/mobile/src/session/use-mobile-native-chat-queued-slot.test.tsx @@ -33,7 +33,8 @@ const CARD = { state: 'waiting' as const, paused: false, needsAttention: false, - caption: null + caption: null, + attribution: null } function Slot({ 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 fe7f7f1eb14a..0af6a74fffbd 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 @@ -533,7 +533,8 @@ describe('mobile structured queued messages', () => { state: 'waiting', paused: false, needsAttention: false, - caption: null + caption: null, + attribution: null } ]) // A frame without the field leaves the list alone; null empties it. 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 902f565b6173..fef63417dd6f 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 @@ -33,7 +33,6 @@ import { type QueuedMessageHoldReason, type QueuedMessageRow } 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' @@ -117,7 +116,6 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string - source: AgentSessionMessageSource holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts index 60d068ebfd8c..d71aa7d97576 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -11,6 +11,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { applyJournalRow, createJournalReducerState, @@ -451,6 +452,35 @@ describe('submission and dispatch state machine', () => { ]) }) + it("folds the echo of another agent's message into its bubble, which keeps the sender", () => { + const echoed = userText('You have 1 orchestration message.') + const from = { + kind: 'agent' as const, + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + const state = fold([ + { + kind: 'submission', + clientMessageId: 'agent-send', + // Stored as the send path stores it: the sender is outside the fingerprint. + payloadFingerprint: agentSessionSendBodyFingerprint('session-1', { ...echoed, from }), + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { ...echoed, from }, + ...base(1) + }, + { kind: 'item', itemId: 'codex:thread-1:turn-1:0', revision: 1, body: echoed, ...base(2) } + ]) + expect(renderJournalState(state).items).toMatchObject([ + { itemId: agentJournalSubmissionKey('agent-send'), body: { from } } + ]) + }) + it.each([5, 10])( 'reconciles %i rapid sends across an interleaved cancel when Codex reuses the root turn', (count) => { diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index 3f9f31154048..27398f9be803 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -22,7 +22,7 @@ import { agentJournalSubmissionKey, parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { JournalDerivedTurnScope } from './journal-derived-turn-scope' import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import { journalItemRevisionIsStale } from './journal-item-revision' @@ -203,11 +203,7 @@ export function journalEchoClaimant( if (!body || !isProviderUserMessageEcho(itemId, body)) { return null } - const fingerprint = structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: state.sessionId, - fields: { body } - }) + const fingerprint = agentSessionSendBodyFingerprint(state.sessionId, body) // Exact payload plus queue order preserves repeated identical sends one-for-one. // A submission an echo may not claim is one that says the message never reached // the provider, so an item resembling it is somebody else's. That is `rejected` 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 cba9c9a9bd51..27951b308e92 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 @@ -337,7 +337,7 @@ export function buildJournalSubmissionRow(input: { ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), ...(input.origin !== undefined ? { origin: input.origin } : {}), - // The kind only: a caller's full source carries senders that stay host-only. + // The kind only; who the senders are is on the message body. ...(input.source !== undefined ? { source: { kind: input.source.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 dda3cd716956..3857d10a4dbc 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 @@ -51,8 +51,7 @@ async function handOff(journal: AgentSessionJournal, draftId: string, submission messageId: draftId, body: BODY, fingerprint: 'fp', - hostInstance: 'p', - source: { kind: 'user' } + hostInstance: 'p' }) await journal.appendSubmission( { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, @@ -101,8 +100,7 @@ describe('the submission names the queued draft it hands off', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp', - hostInstance: 'p', - source: { kind: 'user' } + hostInstance: 'p' }) await expect( journal.appendSubmission( 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 index f559f07db12a..293519d3680f 100644 --- 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 @@ -84,7 +84,12 @@ function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'h /** 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 } }], + senders: [ + { + party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null }, + name: null + } + ], orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } } @@ -237,8 +242,7 @@ describe('which sends an earlier host process left unsent are kept', () => { messageId: 'card', body: message('card text'), fingerprint: fingerprint(message('card text')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await earlier.appendSubmission( { @@ -338,15 +342,13 @@ describe('where kept sends go in the queue', () => { messageId: 'H', body: message('H'), fingerprint: fingerprint(message('H')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await earlier.queuedMessages.insert({ messageId: 'C', body: message('C'), fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) await earlier.appendSubmission( @@ -374,8 +376,7 @@ describe('where kept sends go in the queue', () => { messageId: 'C', body: message('C'), fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1', - source: USER_MESSAGE_SOURCE + hostInstance: 'proc-1' }) await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) @@ -390,7 +391,6 @@ describe('where kept sends go in the queue', () => { 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', 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 index f08d8bb05c4c..f2bf6c953f08 100644 --- 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 @@ -16,7 +16,7 @@ import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued 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 { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import type { AgentSessionJournal } from './journal-store' import type { JournalRowTransactionHook } from './journal-row-writer' import type { QueuedMessagePositionMove } from './queued-message-positions' @@ -46,8 +46,7 @@ export function unsentSendKeptAsCard( return null } const { source } = submission - // Exactly 'user': never `readAgentSessionMessageSource`, whose fallback for what it cannot read - // is the person. + // Exactly 'user': never a fallback that reads a kind it does not know as the person's. const persons = source?.kind === USER_MESSAGE_SOURCE.kind || // A build before `source` was recorded: `client` was only ever a person's send. @@ -117,15 +116,12 @@ export async function holdUnsentSends( ? { messageId: clientMessageId, body: card.body, - fingerprint: structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: journal.queuedMessages.sessionId, - fields: { body: card.body } - }), + fingerprint: agentSessionSendBodyFingerprint( + journal.queuedMessages.sessionId, + 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 } diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 60b613ea307c..5f9a95a53d20 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -65,8 +65,7 @@ async function queueAndConsume(journal: AgentSessionJournal, messageId: string): messageId, body, fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) await journal.appendSubmission( { @@ -173,8 +172,7 @@ describe('draft bookkeeping inside a journal append', () => { messageId: 'draft-1', body: BODY, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) const commit = failNextCommit() diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 88caf16949aa..e6905e848460 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -13,7 +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 { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { JournalQueuedMessages } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener } from './journal-host-database-test-support' @@ -72,13 +72,12 @@ async function handOffAndReject( options: { handedOver: boolean } = { handedOver: true } ): Promise { const body = message(text) - const fingerprint = queuedMessageFingerprint(IDENTITY.sessionId, body) + const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) await journal.queuedMessages.insert({ messageId: 'draft-1', body, fingerprint, - hostInstance: 'p', - source: { kind: 'user' } + hostInstance: 'p' }) await journal.appendSubmission( { @@ -152,7 +151,7 @@ describe("a waiting draft whose 'never delivered' claim an echo disproves", () = const body = message('did it land?') await journal.appendSubmission({ clientMessageId: 'typed-again', - payloadFingerprint: queuedMessageFingerprint(IDENTITY.sessionId, body), + payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body), body, fence: 0, handoverRecorded: true diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts index f111b49037f1..7d739a3f6224 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts @@ -4,7 +4,7 @@ // submission may not claim it), so it is read here, from the row itself. import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { isProviderUserMessageEcho, journalEchoClaimant, @@ -107,11 +107,7 @@ function echoProvesDelivered( ): boolean { return ( sequence > spent.since && - structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: state.sessionId, - fields: { body } - }) === spent.draft.fingerprint + agentSessionSendBodyFingerprint(state.sessionId, body) === spent.draft.fingerprint ) } 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 f9bf0fb2d0bb..3553c0c9ff71 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 @@ -64,7 +64,6 @@ function queueDraft(journal: AgentSessionJournal, messageId: string, carriedFrom body: message(messageId), fingerprint: `fp-${messageId}`, hostInstance: HOST, - source: { kind: 'user' }, ...(carriedFrom ? { carriedFrom } : {}) }) } @@ -497,8 +496,7 @@ describe("a restart's pause", () => { messageId: 'draft-restart', body: message('written before the restart'), fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0', - source: { kind: 'user' } + hostInstance: 'proc-0' }) expect(reason(journal)).toBe('restarted') await queueDraft(journal, 'draft-legacy') @@ -528,7 +526,6 @@ describe("a restart's pause", () => { 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) diff --git a/src/main/native-chat/agent-session-journal/queued-message-schema.ts b/src/main/native-chat/agent-session-journal/queued-message-schema.ts index d19c2e09a2db..3f9d0122fda2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-schema.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-schema.ts @@ -12,8 +12,7 @@ const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [ ['consumed_as', 'TEXT'], ['carried_from', 'TEXT'], ['queued_epoch', 'TEXT'], - ['queued_sequence', 'INTEGER'], - ['source_json', 'TEXT'] + ['queued_sequence', 'INTEGER'] ] /** @@ -44,7 +43,6 @@ CREATE TABLE IF NOT EXISTS queued_messages ( carried_from TEXT, queued_epoch TEXT, queued_sequence INTEGER, - source_json TEXT, PRIMARY KEY (session_id, message_id) ); `) 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 264d17b572a1..7160718124bc 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 @@ -23,7 +23,7 @@ import { QueuedMessageNotConsumableError } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' -import type { AgentSessionMessageSource } from '../../../shared/agent-session-message-source' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { closeTestJournalHostDatabases, createTrackedJournalOpener @@ -83,8 +83,7 @@ async function queueDraft(journal: AgentSessionJournal, messageId: string, text messageId, body: message(text), fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) } @@ -183,8 +182,8 @@ describe('draft rows', () => { expect(journal.queuedMessages.list()).toHaveLength(1) }) - it("keeps who queued a card across reopen; a card with no readable sender is the person's", async () => { - const agent: AgentSessionMessageSource = { + it("keeps an agent card's body as written across reopen: the queue reads nothing of its sender", async () => { + const from: AgentMessageSource = { kind: 'agent', senders: [ { @@ -192,7 +191,8 @@ describe('draft rows', () => { address: 'structworker_1', terminalHandle: 'structworker_1', orcaSessionId: null - } + }, + name: 'Reviewer' } ], orchestration: { @@ -203,31 +203,43 @@ describe('draft rows', () => { } } const first = await open() - await first.queuedMessages.insert({ - messageId: 'agent-card', - body: message('You have 1 orchestration message. Run `orca orchestration check --run r1`.'), - fingerprint: 'fp-agent-card', - hostInstance: 'proc-1', - source: agent - }) - await queueDraft(first, 'before-the-column') - await queueDraft(first, 'unreadable') + const insert = (messageId: string, body: AgentJournalMessageItem) => + first.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await insert('agent-card', { ...message('You have 1 orchestration message.'), from }) + await insert('newer-kind', { + ...message('a task'), + from: { ...from, orchestration: null } + }) + await insert('malformed', message('typed')) await first.close() closeTestJournalHostDatabases() const db = new Database(journalDatabasePath(root)) - db.prepare('UPDATE queued_messages SET source_json = NULL WHERE message_id = ?').run( - 'before-the-column' + // A newer build's message kind, and a value no build writes: carried as written, for clients to read. + const setFrom = db.prepare( + "UPDATE queued_messages SET body_json = json_set(body_json, '$.from', json(?)) WHERE message_id = ?" ) - db.prepare('UPDATE queued_messages SET source_json = \'{"v":9}\' WHERE message_id = ?').run( - 'unreadable' + setFrom.run( + JSON.stringify({ ...from, orchestration: { message: 'task', taskId: 't1' } }), + 'newer-kind' ) + setFrom.run(JSON.stringify('nobody'), 'malformed') + // A table from a build that also kept the sender in a column of its own. + db.exec('ALTER TABLE queued_messages ADD COLUMN source_json TEXT') db.close() const reopened = await open() - expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.source])).toEqual([ - ['agent-card', agent], - ['before-the-column', { kind: 'user' }], - ['unreadable', { kind: 'user' }] + expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.body.from])).toEqual([ + ['agent-card', from], + ['newer-kind', { ...from, orchestration: { message: 'task', taskId: 't1' } }], + ['malformed', 'nobody'] ]) + // That older table still takes new cards. + await queueDraft(reopened, 'after') + expect(reopened.queuedMessages.list()).toHaveLength(4) }) it('drafts survive epoch replacement, which deletes only journal rows', async () => { 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 index f5a10c42073f..1e5fdb2ac03b 100644 --- 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 @@ -3,10 +3,6 @@ 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' @@ -31,7 +27,6 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul carried_from: string | null queued_epoch: string | null queued_sequence: number | null - source_json: string | null } let body: AgentJournalMessageItem try { @@ -70,21 +65,10 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } - : null, - source: storedSource(record.source_json) + : null } } -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 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 d68341888a6f..f3e82613b1f3 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 @@ -17,10 +17,6 @@ import { 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 { readStoredQueuedMessageRow } from './queued-message-stored-row' @@ -70,12 +66,10 @@ export type QueuedMessageRow = { /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null - /** Who it is from: the person, or another agent through Orca. */ - source: AgentSessionMessageSource } const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, source_json' + 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence' export function insertQueuedMessage( db: Database.Database, @@ -87,7 +81,6 @@ export function insertQueuedMessage( hostInstance: string carriedFrom?: string queuedAt: AgentJournalCursor - source: AgentSessionMessageSource now: number /** Absent: after every other card. */ position?: number @@ -101,7 +94,7 @@ export function insertQueuedMessage( const position = input.position ?? Number(highest?.p ?? 0) + 1 db.prepare( `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)` ).run( input.sessionId, input.messageId, @@ -113,8 +106,7 @@ export function insertQueuedMessage( input.holdReason ?? null, input.carriedFrom ?? null, input.queuedAt.epoch, - input.queuedAt.sequence, - serializeAgentSessionMessageSource(input.source) + input.queuedAt.sequence ) return { sessionId: input.sessionId, @@ -132,8 +124,7 @@ export function insertQueuedMessage( settledByOp: null, consumedAs: null, carriedFrom: input.carriedFrom ?? null, - queuedAt: input.queuedAt, - source: input.source + queuedAt: input.queuedAt } } 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 ddfd7c25ff4a..9eb3126c197c 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,6 @@ import { setOptionPlan } from './structured-agent-session-mutation-plans' import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send' -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,10 +60,9 @@ 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 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 + /** Host-local, never on the wire: a person's message the host sends for them, such as a + * launch's first prompt. `userSend` is always one; another agent's message carries `from`. */ + personsMessage?: true beforeRun?: () => void }, arrival?: Parameters[2] 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 6190d74b51b0..4f30c3f1f00d 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 @@ -9,6 +9,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { + AGENT_MESSAGE_SOURCE, USER_MESSAGE_SOURCE, type AgentSessionMessageSource } from '../../../shared/agent-session-message-source' @@ -23,7 +24,10 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command' import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + agentSessionMessagePayload, + agentSessionSendBodyFingerprint +} from '../../../shared/structured-agent-session-send-mutation' import { STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, structuredAgentSessionCompactBody @@ -39,16 +43,6 @@ import { import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' -/** The body-only hash: what the reducer recomputes to alias a provider echo - * onto its submission, so the stored value must never include control fields. */ -function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - export type MutationPlan = { method: string fields: Record @@ -75,14 +69,28 @@ export type MutationPlan = { } ) +/** Who a send is from, read off what it carries, the one place it is decided: the person's own + * send, another agent's message (its body names the sender), or neither, such as a dispatch + * preamble or a restart continuation. */ +function sendSource(params: { + body: AgentJournalMessageItem + userSend?: true + personsMessage?: true +}): AgentSessionMessageSource | undefined { + if (params.userSend || params.personsMessage) { + return USER_MESSAGE_SOURCE + } + return params.body.from ? AGENT_MESSAGE_SOURCE : undefined +} + export function sendPlan(params: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem 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 + /** A person's message the host sends for them; `userSend` is always one. */ + personsMessage?: true beforeRun?: () => void }): MutationPlan { // The operation id IS the client message id: one send, one durable row, one @@ -95,19 +103,23 @@ export function sendPlan(params: { settlesWithWrite: true, // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. - fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, + fields: { + body: agentSessionMessagePayload(params.body), + ...(params.delivery ? { delivery: params.delivery } : {}) + }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. 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 + const source = sendSource(params) return performSend(ctx, { origin: params.userSend ? 'client' : 'host', ...(source ? { source } : {}), clientMessageId, - payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), + // Body-only, so the reducer's echo aliasing never sees control fields or the sender. + payloadFingerprint: agentSessionSendBodyFingerprint(params.envelope.sessionId, params.body), body: params.body }) }, 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 bf2d1a5575e2..58ef7a607ba0 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 @@ -11,6 +11,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' @@ -32,7 +33,7 @@ import { import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } -type RigSendOptions = { internal?: true; source?: AgentMessageSource } +type RigSendOptions = { internal?: true; from?: AgentMessageSource } export function eventually(assertion: () => void | Promise): Promise { return vi.waitFor(assertion, { timeout: 10_000 }) @@ -83,16 +84,18 @@ export async function createQueuedMessageTestRig( } /** A client's send, as the `agentSession.send` RPC hands it to the host; - * `internal` is a host-side sender (orchestration mail, a restart continuation), and `source` - * who it is from. */ + * `internal` is a host-side sender (orchestration mail, a restart continuation), and `from` + * the agent it is from. */ function send(text: string, delivery?: 'queue-if-active', options?: RigSendOptions) { - const body = hostTestMessage(text) + const body = { ...hostTestMessage(text), ...(options?.from ? { from: options.from } : {}) } const clientOperationId = hostTestOperationId() - const fields = { body, ...(delivery ? { delivery } : {}) } + // Fingerprinted as the host digests a send: the message without its sender. + const fields = { body: agentSessionMessagePayload(body), ...(delivery ? { delivery } : {}) } const result = host.send(QUEUED_RIG_CALLER, { envelope: envelope(fields, 'agentSession.send', clientOperationId), ...fields, - ...(options?.internal ? { source: options.source } : { userSend: true as const }) + body, + ...(options?.internal ? {} : { userSend: true as const }) }) return { id: clientOperationId, result } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index eefd106b3eaa..65613b2e621a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -13,6 +13,7 @@ import { } from '../../../shared/agent-session-wire' import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { @@ -637,16 +638,15 @@ describe('/clear', () => { }) }) - it('carries who each card is from', async () => { - const notice = { - message: 'mail-notice', - mailbox: 'run:r1', - dispatchId: null, - messages: [] - } as const - const source: AgentMessageSource = { kind: 'agent', senders: [], orchestration: notice } + it("carries who each card is from on its body, fingerprinted for the replacement's echo", async () => { + const party = { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null } + const from: AgentMessageSource = { + kind: 'agent', + senders: [{ party, name: 'Claude' }], + orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] } + } const working = await workingSend() - await send('pointer', 'queue-if-active', { internal: true, source }).result + await send('pointer', 'queue-if-active', { internal: true, from }).result await send('typed', 'queue-if-active').result await stop() await settleAccepted(working, 'a') @@ -655,11 +655,13 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } - const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(journal?.queuedMessages.list().map((row) => row.source)).toEqual([ - source, - { kind: 'user' } - ]) + const rows = + host.collaboratorsForTests().sessions.get(replacementId)?.journal.queuedMessages.list() ?? [] + expect(rows.map((row) => row.body.from)).toEqual([from, undefined]) + // The sender is outside the fingerprint, or the provider's echo of the text would not match. + expect(rows[0]?.fingerprint).toBe( + agentSessionSendBodyFingerprint(replacementId, hostTestMessage('pointer')) + ) }) it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { 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 3d451a72e0f1..225467d2dbda 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 @@ -8,19 +8,13 @@ import { randomUUID } from 'node:crypto' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - USER_MESSAGE_SOURCE, - type AgentSessionMessageSource -} from '../../../shared/agent-session-message-source' import { QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { - createStructuredAgentSessionOperationId, - structuredAgentSessionPayloadFingerprint -} from '../../../shared/structured-agent-session-mutation' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table' @@ -151,16 +145,6 @@ export function shouldQueueStructuredAgentSessionSend(input: { return oldestActionableQueuedMessage(input.journal) !== null } -/** A draft's payload fingerprint in the session that will send it: the reducer - * aliases the provider's echo to the submission by recomputing exactly this. */ -export function queuedMessageFingerprint(sessionId: string, body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId, - fields: { body } - }) -} - /** The accept-side budget refusal, or null when the draft fits. */ export function queuedMessageBudgetRefusal( journal: AgentSessionJournal, @@ -195,10 +179,6 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - /** A person's send at a chat surface; it outranks any `source`. */ - userSend?: true - /** Who a host-side send is from. */ - source?: AgentSessionMessageSource } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -238,9 +218,9 @@ export async function maybeQueueStructuredAgentSessionSend( { messageId: clientMessageId, body: params.body, - fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance(), - source: params.userSend ? USER_MESSAGE_SOURCE : (params.source ?? USER_MESSAGE_SOURCE) + // In the session that will send it: the reducer aliases the provider's echo by exactly this. + fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance() }, ctx.operationReceipt ) 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 0eb300fdef81..33142a830a50 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 @@ -23,10 +23,8 @@ import { } from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { MutationPlan } from './structured-agent-session-mutation-plans' -import { - queuedMessageFingerprint, - structuredQueueHold -} from './structured-agent-session-queued-messages' +import { structuredQueueHold } from './structured-agent-session-queued-messages' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { resumeStructuredQueue, structuredAgentSessionHostInstance @@ -116,10 +114,9 @@ export async function carryQueuedMessagesToClearReplacement( await replacement.queuedMessages.insert({ messageId: row.messageId, body: row.body, - fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body), + fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - 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 } 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 d27d521c40e8..1b394ebaf558 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,6 @@ import type { AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -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' @@ -16,8 +15,6 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' - userSend?: true - source?: AgentSessionMessageSource }, immediate: () => Promise> ): Promise> { 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 d53d06f35a00..b19f2a49d77c 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 @@ -559,8 +559,7 @@ describe('startup restore of chats still in their per-chat files', () => { messageId: 'draft-1', body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, fingerprint: 'fp-draft-1', - hostInstance: 'proc-1', - source: { kind: 'user' } + hostInstance: 'proc-1' }) expect(journal.importPending).toBe(false) 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 index 0219207a6ccb..2885cf1d4c8a 100644 --- 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 @@ -33,7 +33,12 @@ import { /** 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 } }], + senders: [ + { + party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null }, + name: null + } + ], orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } } @@ -518,13 +523,14 @@ describe('the same send arriving again after the restart', () => { }) describe('what is not kept', () => { - // Mail names its sender (an agent); a dispatch preamble names none. Neither is a person's. + // Mail names its sender on its body (an agent); a dispatch preamble names none. Neither is a + // person's, and the submission's kind is read off that. 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 }) => { + { by: 'mail', from: MAIL_SOURCE, recorded: { kind: 'agent' } }, + { by: 'dispatch', from: undefined, recorded: undefined } + ])('an orchestration $by send is rejected, as before', async ({ by, from, recorded }) => { const { userSend: _person, ...sent } = sendRequest(by) - await acceptWhileStarting({ ...sent, ...(source ? { source } : {}) }) + await acceptWhileStarting({ ...sent, ...(from ? { body: { ...sent.body, from } } : {}) }) await rig.crashRestartHostProcess() expect(await rig.drafts()).toEqual([]) const submission = await rig.submission(sent.envelope.clientOperationId) @@ -532,6 +538,18 @@ describe('what is not kept', () => { expect(submission?.source).toEqual(recorded) }) + // Not a client's send, but the person's: the host sends a launch's first prompt for them. + it("keeps a launch's first prompt, which the host sends for the person", async () => { + const { userSend: _client, ...sent } = sendRequest('the launch prompt') + await acceptWhileStarting({ ...sent, personsMessage: true }) + await rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: sent.envelope.clientOperationId, ...KEPT }]) + expect(await rig.submission(sent.envelope.clientOperationId)).toMatchObject({ + origin: 'host', + source: { kind: 'user' } + }) + }) + 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) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts index 3fb9495501e8..4462f1a9a9c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -1,6 +1,9 @@ import { 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 { + AgentJournalItemBody, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { AgentSessionRewindRecordSchema, type AgentSessionRewindRecord @@ -145,6 +148,44 @@ describe('rewind keeps each retained row attributed', () => { }) }) +describe("a rewind keeps another agent's messages its", () => { + const from = { + kind: 'agent' as const, + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Worker' + } + ], + orchestration: null + } + const user = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }] + }) + + it("puts the sender back on the provider's copy, which never carries it", () => { + const itemId = codexKey('turn-1', 0) + const merged = mergeRetainedHostLifecycleRows( + [retained(itemId, { ...user('You have 1 orchestration message.'), from })], + [providerItem(itemId, user('You have 1 orchestration message.'))] + ) + expect(merged).toHaveLength(1) + expect(merged[0]?.body).toMatchObject({ from }) + }) + + it("leaves the person's own message, and a provider row of another role, without one", () => { + const own = codexKey('turn-1', 0) + const answer = codexKey('turn-1', 1) + const merged = mergeRetainedHostLifecycleRows( + [retained(own, user('mine')), retained(answer, { ...user('hi'), from })], + [providerItem(own, user('mine')), providerItem(answer, prose('hi'))] + ) + expect(merged.map((row) => 'from' in row.body)).toEqual([false, false]) + }) +}) + describe('the rebuilt epoch item for a retained row', () => { it('passes a placeable scope and a known producer through', () => { expect( diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index b1cbbe22b74a..f41e83522d2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -6,7 +6,10 @@ import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AGENT_JOURNAL_ITEM_BODY_KINDS } from '../../../shared/agent-session-journal-schemas' +import { + AGENT_JOURNAL_ITEM_BODY_KINDS, + isAdmissibleAgentJournalMessageBody +} from '../../../shared/agent-session-journal-schemas' import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -93,13 +96,30 @@ export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLi } /** A provider item the old epoch held keeps that row's scope, producer and first-seen time; only - * its body is the provider's. */ + * its body is the provider's, and a user message keeps the sender the host recorded on it. */ function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { if (!held) { return item } - const { itemId: _itemId, body: _body, ...attribution } = held - return { ...item, ...attribution } + const { itemId: _itemId, body: heldBody, ...attribution } = held + return { ...item, ...attribution, body: withHeldSender(item.body, heldBody) } +} + +/** The provider's copy of a user message, with the sender the host recorded on it put back: no + * provider ever carries it. */ +function withHeldSender( + provider: RetainedRow['body'], + held: RetainedRow['body'] +): RetainedRow['body'] { + if ( + !isAdmissibleAgentJournalMessageBody(held) || + !isAdmissibleAgentJournalMessageBody(provider) + ) { + return provider + } + return held.from !== undefined && held.role === 'user' && provider.role === 'user' + ? Object.assign({}, provider, { from: held.from }) + : provider } /** Provider turn id → the item id of its turn record: the turn's own, or the command turn that diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 8b406b2df6a6..cfb9f26e4ad5 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -2,6 +2,7 @@ import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' import { localOrchestrationCliCommand } from './orchestration/cli-command' +import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' @@ -255,9 +256,20 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), getCliCommand: localOrchestrationCliCommand, + senderName: (party, reportedDispatchId) => + this.orchestrationSenderNames.nameOf(party, reportedDispatchId), host: createStructuredMailboxPointerHost() }) + /** What Orca calls an agent that sends through it; public for every such send site. */ + readonly orchestrationSenderNames = new RuntimeOrchestrationSenderNames({ + getDb: () => this._orchestrationDb, + getHandleRecord: (handle) => this.handles.get(handle), + getPtyAgents: (ptyId) => this.ptysById.get(ptyId), + getTerminalPaneKey: (handle) => this.getTerminalPaneKey(handle), + getWorkspaceSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId) + }) + protected readonly orchestrationMailboxNotifications = new OrchestrationMailboxNotificationCoordinator({ mailboxOwner: this.orchestrationMailboxOwner, diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index e2926d5bf9a3..6aee5f6b955d 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -120,6 +120,7 @@ async function renderChatPointer(mailbox: string): Promise { resolveStructuredTarget: () => ({ sessionId: CHAT_SESSION, dispatchId: null }), // The runtime's wiring of the structured lane. getCliCommand: localOrchestrationCliCommand, + senderName: () => null, host: { readSessionFacts: async () => ({ submissions: [] }), currentFence: () => 1, diff --git a/src/main/runtime/orchestration/agent-message-sender.ts b/src/main/runtime/orchestration/agent-message-sender.ts new file mode 100644 index 000000000000..b2be38933271 --- /dev/null +++ b/src/main/runtime/orchestration/agent-message-sender.ts @@ -0,0 +1,51 @@ +// The one way any Orca send that speaks for another agent records who it is from: the sender's +// party as orchestration resolves its address, and a bounded snapshot of its name. + +import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { + agentMessageSenderName, + type AgentMessageSender +} from '../../../shared/agent-session-message-source' +import type { OrchestrationDb } from './db' +import { resolveOrchestrationParty } from './orchestration-party' + +/** A name from Orca's records for a party (never an agent-painted title); null when it has none. + * `reportedDispatchId`: the dispatch the sender's own `worker_done` in the message names. */ +export type SenderNameResolver = ( + party: AgentMessageSender['party'], + reportedDispatchId?: string +) => string | null + +/** A sender as a message records it, named now, for when it is gone. */ +export function agentMessageSender( + address: string, + db: OrchestrationDb | null, + senderName: SenderNameResolver, + reportedDispatchId?: string +): AgentMessageSender { + const party = senderParty(address, db) + return { party, name: snapshotName(senderName, party, reportedDispatchId) } +} + +function senderParty(address: string, db: OrchestrationDb | null): AgentMessageSender['party'] { + try { + const { paneKey: _credential, ...party } = resolveOrchestrationParty(address, db) + return party + } catch { + // A worker this host lost the identity of: what the address itself says. + return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) } + } +} + +function snapshotName( + senderName: SenderNameResolver, + party: AgentMessageSender['party'], + reportedDispatchId: string | undefined +): string | null { + try { + return agentMessageSenderName(senderName(party, reportedDispatchId)) + } catch { + // A name is a label, never a reason the message is not delivered. + return null + } +} diff --git a/src/main/runtime/orchestration/orchestration-party-location.test.ts b/src/main/runtime/orchestration/orchestration-party-location.test.ts new file mode 100644 index 000000000000..c51bd3046a4b --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-location.test.ts @@ -0,0 +1,159 @@ +// Where a message's sender opens: the host's read of a party, over the records production keeps. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { OrchestrationDb } from './db' +import { createRootDispatch } from './db/root-dispatch-test-fixture' +import { locateOrchestrationParty } from './orchestration-party-location' +import { OrchestrationError } from './orchestration-error' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import type * as OrchestrationParty from './orchestration-party' + +// The real resolver, which a test can make fail once. +const resolveParty = vi.hoisted(() => vi.fn()) +vi.mock('./orchestration-party', async (importOriginal) => { + const actual = await importOriginal() + resolveParty.mockImplementation(actual.resolveOrchestrationParty) + return { ...actual, resolveOrchestrationParty: resolveParty } +}) +import type { AgentSessionRecordReader } from './structured-session-lineage' + +const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const LIVE = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +const PANE = 'tab_worker:leaf' + +let db: OrchestrationDb + +beforeEach(() => { + db = new OrchestrationDb(':memory:') +}) + +afterEach(() => { + db.close() +}) + +function record(sessionId: string, overrides: Partial = {}) { + const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })) + return { ...base, location: { ...base.location, workspaceId: 'wt-chat' }, ...overrides } +} + +/** A chat `/clear` continued in LIVE: its root id still names it. */ +function clearedChat(): AgentSessionRecordReader { + const records = new Map([ + [ + ROOT, + record(ROOT, { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: LIVE, + operationId: 'op', + callerKey: 'k', + phase: 'committed' + } + }) + ], + [LIVE, record(LIVE)] + ]) + return { getRecord: (id) => records.get(id) ?? null, listRecords: () => [...records.values()] } +} + +/** This run issued `term_now` for PANE; any other handle is from an earlier run. */ +function locate( + address: string, + deps: Partial[1]> = {}, + messageIds?: readonly string[] +) { + return locateOrchestrationParty( + address, + { + db, + records: null, + terminalPaneKey: (handle) => (handle === 'term_now' ? PANE : null), + terminalHandleForPaneKey: (paneKey) => (paneKey === PANE ? 'term_now' : null), + ...deps + }, + messageIds + ) +} + +describe('where a sender opens', () => { + it("opens a chat at its `/clear` lineage's live session, in that session's worktree", () => { + expect(locate(`orca_session_id:${ROOT}`, { records: clearedChat() })).toEqual({ + location: { kind: 'chat', sessionId: LIVE, worktreeId: 'wt-chat' } + }) + expect(locate(`orca_session_id:${ROOT}`)).toEqual({ location: null, lost: 'chat' }) + }) + + it('opens a terminal this run issued at its handle', () => { + expect(locate('term_now')).toEqual({ location: { kind: 'terminal', handle: 'term_now' } }) + }) + + it('finds a terminal from an earlier run through the pane its mail was sent from', () => { + const sent = db.insertMessage({ + from: 'term_old', + to: 'term_chat', + subject: 's', + senderPaneKey: PANE + }) + const other = db.insertMessage({ + from: 'term_other', + to: 'term_chat', + subject: 's', + senderPaneKey: PANE + }) + expect(locate('term_old', {}, [sent.id])).toEqual({ + location: { kind: 'terminal', handle: 'term_now' } + }) + // Only mail the sender itself sent can place it, and without mail the old handle is lost. + expect(locate('term_old', {}, [other.id])).toEqual({ location: null, lost: 'terminal' }) + expect(locate('term_old')).toEqual({ location: null, lost: 'terminal' }) + }) + + it("opens a dispatch at its assignee's terminal, through the pane it was assigned", () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'work' }) + const dispatch = createRootDispatch(db, task.id, 'term_old', PANE) + expect(locate(`dispatch:${dispatch.id}`)).toEqual({ + location: { kind: 'terminal', handle: 'term_now' } + }) + expect(locate('dispatch:gone')).toEqual({ location: null }) + }) + + it('never calls a dispatch this host does not run gone: a federated one has no assignee here', () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'remote work' }) + // At the run's home, a federated worker's dispatch is a row with no local assignee. + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + expect(locate(`dispatch:${started.dispatch.id}`)).toEqual({ location: null }) + }) + + it('calls a chat gone only on proof, and leaves any other failure to the caller', () => { + resolveParty.mockImplementationOnce(() => { + throw new OrchestrationError(CODES.notLive, 'lost its identity') + }) + expect(locate(`orca_session_id:${ROOT}`)).toEqual({ location: null, lost: 'chat' }) + resolveParty.mockImplementationOnce(() => { + throw new Error('database is locked') + }) + expect(() => locate(`orca_session_id:${ROOT}`)).toThrow('database is locked') + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-party-location.ts b/src/main/runtime/orchestration/orchestration-party-location.ts new file mode 100644 index 000000000000..e4d632a1cfb6 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-location.ts @@ -0,0 +1,106 @@ +// Where a party named by its mailbox address is now, so a chat can open a message's sender. + +import type { OrchestrationPartyLocationResult } from '../../../shared/orchestration-caller-status' +import type { OrcaSessionId } from '../../../shared/orca-session-address' +import type { OrchestrationDb } from './db' +import { resolveOrchestrationParty } from './orchestration-party' +import { OrchestrationError } from './orchestration-error' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +type PartyLocationDeps = { + db: OrchestrationDb | null + records: AgentSessionRecordReader | null + /** Non-null only for a handle this runtime issued. */ + terminalPaneKey: (handle: string) => string | null + terminalHandleForPaneKey: (paneKey: string) => string | null +} + +/** `messageIds`: the mail the message carried from this party, whose sender pane outlives a + * handle a previous run issued. */ +export function locateOrchestrationParty( + address: string, + deps: PartyLocationDeps, + messageIds: readonly string[] = [] +): OrchestrationPartyLocationResult { + if (address.startsWith('dispatch:')) { + return locateDispatchAssignee(address.slice('dispatch:'.length), deps) + } + let party: ReturnType + try { + party = resolveOrchestrationParty(address, deps.db) + } catch (error) { + // Only a worker this host proves it lost the identity of is gone; any other failure is the + // caller's to report as unreachable. + if (error instanceof OrchestrationError && error.code === CODES.notLive) { + return { location: null, lost: 'chat' } + } + throw error + } + if (party.orcaSessionId) { + return locateSession(party.orcaSessionId, deps.records) + } + return party.terminalHandle + ? locateTerminal(party.terminalHandle, deps, messageIds) + : { location: null, lost: 'terminal' } +} + +/** A handle is issued per run, so one from an earlier run is found again through the pane its mail + * was sent from, as mail delivery finds it. */ +function locateTerminal( + handle: string, + deps: PartyLocationDeps, + messageIds: readonly string[] +): OrchestrationPartyLocationResult { + if (deps.terminalPaneKey(handle) !== null) { + return { location: { kind: 'terminal', handle } } + } + for (const messageId of messageIds) { + const message = deps.db?.getMessageById(messageId) + const paneKey = message?.from_handle === handle ? message.sender_pane_key : null + const live = paneKey ? deps.terminalHandleForPaneKey(paneKey) : null + if (live) { + return { location: { kind: 'terminal', handle: live } } + } + } + return { location: null, lost: 'terminal' } +} + +/** As mail to the dispatch is routed (mailbox-delivery-target.ts): its assignee's pane, else + * its recorded handle, else a remote attachment's. A dispatch whose worker this host does not run + * (a federated one, at its run's home) is unknown here, never gone: not seeing a worker is no + * evidence it stopped. */ +function locateDispatchAssignee( + dispatchId: string, + deps: PartyLocationDeps +): OrchestrationPartyLocationResult { + const dispatch = deps.db?.getDispatchContextById(dispatchId) + if (dispatch?.assignee_orca_session_id) { + return locateSession(dispatch.assignee_orca_session_id, deps.records) + } + const remote = dispatch ? undefined : deps.db?.getRemoteDispatchAttachment(dispatchId) + const paneKey = dispatch?.assignee_pane_key ?? remote?.pane_key + const handle = + (paneKey ? deps.terminalHandleForPaneKey(paneKey) : null) ?? + dispatch?.assignee_handle ?? + remote?.terminal_handle + return handle && !handle.startsWith('dispatch:') + ? locateOrchestrationParty(handle, deps) + : { location: null } +} + +function locateSession( + orcaSessionId: OrcaSessionId, + records: AgentSessionRecordReader | null +): OrchestrationPartyLocationResult { + const record = records ? lineageLiveSession(records, orcaSessionId) : null + return record + ? { + location: { + kind: 'chat', + sessionId: record.sessionId, + worktreeId: record.location.workspaceId + } + } + : { location: null, lost: 'chat' } +} diff --git a/src/main/runtime/orchestration/orchestration-party.ts b/src/main/runtime/orchestration/orchestration-party.ts index 579b36d9216b..bc1cdf471346 100644 --- a/src/main/runtime/orchestration/orchestration-party.ts +++ b/src/main/runtime/orchestration/orchestration-party.ts @@ -26,14 +26,16 @@ export type OrchestrationSessionParty = OrchestrationParty & const NO_EFFECTS = { effectsApplied: false } as const /** Every param naming a party other than the caller; a new one is added here with its own test. */ -export const ORCHESTRATION_TARGET_PARAM: Readonly> = - { - 'orchestration.send': 'to', - 'orchestration.ask': 'to', - 'orchestration.dispatch': 'to', - 'orchestration.inbox': 'terminal', - 'orchestration.sessionAddress': 'sessionId' - } +export const ORCHESTRATION_TARGET_PARAM: Readonly< + Record +> = { + 'orchestration.send': 'to', + 'orchestration.ask': 'to', + 'orchestration.dispatch': 'to', + 'orchestration.inbox': 'terminal', + 'orchestration.sessionAddress': 'sessionId', + 'orchestration.partyLocation': 'address' +} /** The party an Orca session id names. Throws when it is a worker this host lost the identity of. */ export function resolveOrcaSessionParty( diff --git a/src/main/runtime/orchestration/orchestration-sender-name.ts b/src/main/runtime/orchestration/orchestration-sender-name.ts new file mode 100644 index 000000000000..559269336872 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-sender-name.ts @@ -0,0 +1,97 @@ +// What a chat shows a message's sender as: names from Orca's own records, most specific first, and +// never a title the agent paints on its own terminal. + +import type { AgentMessageSender } from '../../../shared/agent-session-message-source' +import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' +import type { AgentType } from '../../../shared/agent-status-types' +import { formatAgentTypeLabel } from '../../../shared/agent-type-label' +import type { Tab } from '../../../shared/tab-types' +import type { OrchestrationDb } from './db' +import type { DispatchContextRow } from './types' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' + +export type TerminalSenderNaming = { + /** The tab's stored title: a rename, by the person or through the CLI. Never its live title. */ + customTitle: string | null + agent: AgentType | null + /** Its pane, which an active dispatch still names after the handle was reissued. */ + paneKey: string | null +} + +export type SenderNamingSources = { + db: OrchestrationDb | null + records: AgentSessionRecordReader | null + /** The chat's tab as this host mirrors the workspace session. */ + chatTab: (worktreeId: string, sessionId: string) => Pick | null + terminal: (handle: string) => TerminalSenderNaming | null +} + +type Party = AgentMessageSender['party'] + +/** `reportedDispatchId`: the dispatch the sender's own `worker_done` in the batch names. */ +export function orchestrationSenderName( + party: Party, + sources: SenderNamingSources, + reportedDispatchId?: string +): string | null { + const federated = party.address.startsWith('dispatch:') + const terminal = + !federated && party.terminalHandle ? sources.terminal(party.terminalHandle) : null + const task = dispatchTaskName(party, federated, terminal, sources.db, reportedDispatchId) + if (task) { + return task + } + if (party.orcaSessionId) { + const record = sources.records ? lineageLiveSession(sources.records, party.orcaSessionId) : null + if (record) { + const tab = sources.chatTab(record.location.workspaceId, record.sessionId) + return tab?.customLabel?.trim() || tab?.label.trim() || defaultAgentChatLabel(record.provider) + } + } + if (terminal) { + const agentLabel = terminal.agent ? formatAgentTypeLabel(terminal.agent) : null + return terminal.customTitle?.trim() || agentLabel + } + return null +} + +/** The task its dispatch was given: the federated `dispatch:` address, or the dispatch a local + * worker holds, else the one its own `worker_done` reports, since that report settles its dispatch + * before it is delivered. Never a dispatch the worker merely held once: one may have failed + * before it ran, and an old one names work long done. */ +function dispatchTaskName( + party: Party, + federated: boolean, + terminal: TerminalSenderNaming | null, + db: OrchestrationDb | null, + reportedDispatchId: string | undefined +): string | null { + if (!db) { + return null + } + const handle = party.terminalHandle + const paneKey = terminal?.paneKey ?? undefined + const dispatch = federated + ? db.getDispatchContextById(party.address.slice('dispatch:'.length)) + : handle + ? (db.getActiveDispatchForTerminal(handle, paneKey) ?? + reportedDispatch(db, reportedDispatchId, handle, paneKey)) + : undefined + const task = dispatch ? db.getTask(dispatch.task_id) : undefined + return task?.display_name || task?.task_title || null +} + +/** Only the sender's own dispatch: a report's payload is the sender's to write. */ +function reportedDispatch( + db: OrchestrationDb, + dispatchId: string | undefined, + handle: string, + paneKey: string | undefined +): DispatchContextRow | undefined { + const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) : undefined + return dispatch && + (dispatch.assignee_handle === handle || + (paneKey !== undefined && dispatch.assignee_pane_key === paneKey)) + ? dispatch + : undefined +} 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 9ba15ea1765c..df50c6fad03a 100644 --- a/src/main/runtime/orchestration/send-agent-turn-host.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn-host.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { createQueuedMessageTestRig, eventually, @@ -29,7 +30,8 @@ const MAIL_SOURCE: AgentMessageSource = { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null - } + }, + name: 'Claude' } ], orchestration: { @@ -57,14 +59,20 @@ function sendTurn( sessionId: SESSION, callerKey: 'trusted-local:orchestration:d1', turn: { - body: hostTestMessage('mail'), + body: { ...hostTestMessage('mail'), from: MAIL_SOURCE }, operationId, expectedRuntimeFence: 1, - ...(delivery === 'queue' ? { delivery, source: MAIL_SOURCE } : { delivery }) + delivery } }) } +/** The transcript item a send became. */ +async function sentMessage(clientMessageId: string) { + const snapshot = await rig.host.journalSnapshot(SESSION) + return snapshot.items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId)) +} + describe('sendAgentTurn through the real host', () => { it('has a `queue` send held as a draft while the agent works', async () => { await rig.workingSend() @@ -72,16 +80,26 @@ describe('sendAgentTurn through the real host', () => { kind: 'queued', queued: { position: 1, state: 'waiting' } }) - // Stored with the card, read back whole: who it is from survives the round trip. + // On the card's body, read back whole and published with it. expect( rig.host .collaboratorsForTests() .sessions.get(SESSION) ?.journal.queuedMessages.list() - .map(({ state, source }) => ({ state, source })) - ).toEqual([{ state: 'waiting', source: MAIL_SOURCE }]) - // Shown in the chat's queue like the person's own card. - expect(await rig.drafts()).toMatchObject([{ state: 'waiting' }]) + .map(({ state, body }) => ({ state, from: body.from })) + ).toEqual([{ state: 'waiting', from: MAIL_SOURCE }]) + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.map(({ body }) => body.from)).toEqual([MAIL_SOURCE]) + }) + + it('keeps the sender on the turn the queue sends', async () => { + const working = await rig.workingSend() + const operationId = hostTestOperationId() + await sendTurn('queue', operationId) + await rig.settleAccepted(working, 'work') + await eventually(async () => expect(await rig.handoff(operationId)).toBeDefined()) + const item = await sentMessage(await rig.handoffId(operationId)) + expect(item?.body).toMatchObject({ from: MAIL_SOURCE }) }) it('replays a retried `queue` send instead of refusing it', async () => { @@ -138,11 +156,12 @@ describe('sendAgentTurn through the real host', () => { } it.each(['now', 'queue'] as const)('sends a `%s` turn to an idle agent', async (delivery) => { - await expect(sendTurnAccepted(delivery)).resolves.toMatchObject({ - kind: 'sent', - submission: { dispatchState: 'accepted' } - }) + const outcome = await sendTurnAccepted(delivery) + expect(outcome).toMatchObject({ kind: 'sent', submission: { dispatchState: 'accepted' } }) expect(await rig.drafts()).toEqual([]) + // An idle chat never queues, and the sent message still names its sender. + const sent = outcome.kind === 'sent' ? outcome.clientMessageId : '' + expect((await sentMessage(sent))?.body).toMatchObject({ from: MAIL_SOURCE }) }) // An idle chat sends the mail at once: its submission says it is an agent's, without the senders diff --git a/src/main/runtime/orchestration/send-agent-turn.test.ts b/src/main/runtime/orchestration/send-agent-turn.test.ts index 599ef009ae55..bd90030a8bc4 100644 --- a/src/main/runtime/orchestration/send-agent-turn.test.ts +++ b/src/main/runtime/orchestration/send-agent-turn.test.ts @@ -55,7 +55,12 @@ function structuredHost(answer: HostSendAnswer, settled?: AgentJournalSubmission const MAIL_SOURCE: AgentMessageSource = { kind: 'agent', - senders: [], + senders: [ + { + party: { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null }, + name: 'Claude' + } + ], orchestration: { message: 'mail-notice', mailbox: 'dispatch:d1', @@ -71,6 +76,12 @@ const turn: StructuredSessionTurn = { expectedRuntimeFence: 7 } +const mailTurn: StructuredSessionTurn = { + ...turn, + body: { ...turn.body, from: MAIL_SOURCE }, + delivery: 'queue' +} + const structured = (host: StructuredAgentTurnHost, sent: StructuredSessionTurn = turn) => ({ kind: 'structured-session', @@ -146,16 +157,14 @@ describe('sendAgentTurn to a structured session', () => { }) }) - it('asks the host queue to hold a `queue` send, fingerprinted as the host digests it', async () => { + it('asks the host queue to hold a `queue` send, fingerprinted without its sender', async () => { const fake = structuredHost( accepted({ clientMessageId: 'op-1', queued: { messageId: 'op-1', position: 0, state: 'waiting' } }) ) - await expect( - sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE })) - ).resolves.toEqual({ + await expect(sendAgentTurn(structured(fake.host, mailTurn))).resolves.toEqual({ kind: 'queued', clientMessageId: 'op-1', queued: { messageId: 'op-1', position: 0, state: 'waiting' } @@ -167,12 +176,11 @@ describe('sendAgentTurn to a structured session', () => { sessionId: 's1', clientOperationId: 'op-1', expectedRuntimeFence: 7, + // The sender rides on the body, outside the fingerprint: a retry still replays. payloadFingerprint: hostFingerprint({ body: turn.body, delivery: 'queue-if-active' }) }, - body: turn.body, - delivery: 'queue-if-active', - // Host-local: who the card is from rides beside the envelope, outside its fingerprint. - source: MAIL_SOURCE + body: mailTurn.body, + delivery: 'queue-if-active' } ) expect(fake.waitForSendSettlement).not.toHaveBeenCalled() @@ -185,9 +193,10 @@ describe('sendAgentTurn to a structured session', () => { queued: { messageId: 'op-1', position: 0, state: 'returned' } }) ) - await expect( - sendAgentTurn(structured(fake.host, { ...turn, delivery: 'queue', source: MAIL_SOURCE })) - ).resolves.toMatchObject({ kind: 'queued', queued: { state: 'returned' } }) + await expect(sendAgentTurn(structured(fake.host, mailTurn))).resolves.toMatchObject({ + kind: 'queued', + queued: { state: 'returned' } + }) }) }) diff --git a/src/main/runtime/orchestration/send-agent-turn.ts b/src/main/runtime/orchestration/send-agent-turn.ts index 340bcc82e87a..9805a346b7ee 100644 --- a/src/main/runtime/orchestration/send-agent-turn.ts +++ b/src/main/runtime/orchestration/send-agent-turn.ts @@ -17,7 +17,6 @@ import { type AgentSessionQueuedSendReceipt } from '../../../shared/agent-session-wire' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets' import { structuredAgentSessionMessageSendMutation } from '../../../shared/structured-agent-session-send-mutation' import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host' @@ -36,15 +35,13 @@ export type StructuredAgentTurnHost = Pick< > export type StructuredSessionTurn = { + /** Carries its sender as `from`, which the chat shows and no fingerprint covers. */ body: AgentJournalMessageItem /** Reused on a retry, so the host replays its recorded answer instead of sending twice. */ operationId: string expectedRuntimeFence: number -} & ( - | { delivery: 'now' } - /** A queued card records who it is from. */ - | { delivery: 'queue'; source: AgentMessageSource } -) + delivery: AgentTurnDelivery +} export type StructuredSessionTurnSend = { kind: 'structured-session' @@ -134,11 +131,7 @@ async function sendStructuredSessionTurn( body: turn.body, delivery: turn.delivery === 'queue' ? 'queue-if-active' : undefined }) - const result = await send.host.send( - { callerKey: send.callerKey }, - // The source is host-local and outside the fingerprint: a retry under the same id replays. - turn.delivery === 'queue' ? { ...message, source: turn.source } : message - ) + const result = await send.host.send({ callerKey: send.callerKey }, message) if (!result.ok) { return { kind: 'refused', refusal: result.refusal } } diff --git a/src/main/runtime/orchestration/structured-mail-source.test.ts b/src/main/runtime/orchestration/structured-mail-source.test.ts index f6f394d722e2..d9b9b4e7fb0e 100644 --- a/src/main/runtime/orchestration/structured-mail-source.test.ts +++ b/src/main/runtime/orchestration/structured-mail-source.test.ts @@ -11,21 +11,32 @@ describe('who delivered mail is from', () => { mailboxHandle: 'run:r1', dispatchId: null, batch: [ - { id: 'm1', from_handle: 'term_a', run_id: 'r1' }, - { id: 'm2', from_handle: `orca_session_id:${SESSION}`, run_id: 'r2' }, - { id: 'm3', from_handle: 'term_a', run_id: 'r1' } - ] + { id: 'm1', from_handle: 'term_a', run_id: 'r1', type: 'status', payload: null }, + { + id: 'm2', + from_handle: `orca_session_id:${SESSION}`, + run_id: 'r2', + type: 'status', + payload: null + }, + { id: 'm3', from_handle: 'term_a', run_id: 'r1', type: 'status', payload: null } + ], + senderName: (party) => (party.terminalHandle === 'term_a' ? 'Build tab' : null) }) expect(source).toEqual({ kind: 'agent', senders: [ - { party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } }, + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Build tab' + }, { party: { address: `orca_session_id:${SESSION}`, terminalHandle: null, orcaSessionId: SESSION - } + }, + name: null } ], orchestration: { @@ -40,4 +51,22 @@ describe('who delivered mail is from', () => { } }) }) + + it('bounds a snapshot name, and a naming failure costs only the name', () => { + const named = (senderName: (party: unknown) => string | null) => + structuredMailSource({ + db: null, + mailboxHandle: 'run:r1', + dispatchId: null, + batch: [{ id: 'm1', from_handle: 'term_a', run_id: 'r1', type: 'status', payload: null }], + senderName + }).senders[0]?.name + expect(named(() => ` Build\n${'x'.repeat(400)}`)).toHaveLength(200) + expect(named(() => ' ')).toBeNull() + expect( + named(() => { + throw new Error('records unavailable') + }) + ).toBeNull() + }) }) diff --git a/src/main/runtime/orchestration/structured-mail-source.ts b/src/main/runtime/orchestration/structured-mail-source.ts index ad26c3b8be05..624ecc9d16b0 100644 --- a/src/main/runtime/orchestration/structured-mail-source.ts +++ b/src/main/runtime/orchestration/structured-mail-source.ts @@ -4,26 +4,37 @@ * and message ids join back to orchestration's own rows while those exist. */ -import { parseOrcaSessionAddress } from '../../../shared/orca-session-address' import type { AgentMessageSource, AgentMessageSender } from '../../../shared/agent-session-message-source' import type { MessageRow, OrchestrationDb } from './db' -import { resolveOrchestrationParty } from './orchestration-party' +import { agentMessageSender, type SenderNameResolver } from './agent-message-sender' -export type MailSourceMessage = Pick +export type MailSourceMessage = Pick< + MessageRow, + 'id' | 'from_handle' | 'run_id' | 'type' | 'payload' +> export function structuredMailSource(input: { db: OrchestrationDb | null mailboxHandle: string dispatchId: string | null batch: readonly MailSourceMessage[] + senderName: SenderNameResolver }): AgentMessageSource { const senders = new Map() for (const { from_handle: address } of input.batch) { if (!senders.has(address)) { - senders.set(address, { party: senderParty(address, input.db) }) + senders.set( + address, + agentMessageSender( + address, + input.db, + input.senderName, + reportedDispatchId(input.batch, address) + ) + ) } } return { @@ -42,12 +53,28 @@ export function structuredMailSource(input: { } } -function senderParty(address: string, db: OrchestrationDb | null): AgentMessageSender['party'] { - try { - const { paneKey: _credential, ...party } = resolveOrchestrationParty(address, db) - return party - } catch { - // A worker this host lost the identity of: what the address itself says. - return { address, terminalHandle: null, orcaSessionId: parseOrcaSessionAddress(address) } +/** The dispatch a sender's own `worker_done` here reports: the task it just finished, whose + * dispatch that report already settled. */ +function reportedDispatchId( + batch: readonly MailSourceMessage[], + address: string +): string | undefined { + for (const message of batch) { + if (message.from_handle !== address || message.type !== 'worker_done' || !message.payload) { + continue + } + try { + const payload: unknown = JSON.parse(message.payload) + const dispatchId = + typeof payload === 'object' && payload !== null && 'dispatchId' in payload + ? payload.dispatchId + : undefined + if (typeof dispatchId === 'string' && dispatchId.length > 0) { + return dispatchId + } + } catch { + // A payload that does not parse names no dispatch. + } } + return undefined } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 46090094c0d2..fb78b74a3763 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -73,6 +73,7 @@ function harness(options: { resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, getCliCommand: () => 'orca-dev', + senderName: (party) => (party.address === 'term_coord' ? 'Coordinator' : null), host: { readSessionFacts: async () => (attached ? { submissions } : null), currentFence: () => 4, @@ -170,9 +171,9 @@ describe('structured mailbox pointer delivery', () => { expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({ text: expect.stringContaining('orca-dev orchestration check') }) - expect(send.mock.calls[0]![0].source).toMatchObject({ + expect(send.mock.calls[0]![0].body.from).toMatchObject({ kind: 'agent', - senders: [{ party: { address: 'term_coord' } }], + senders: [{ party: { address: 'term_coord' }, name: 'Coordinator' }], orchestration: { message: 'mail-notice', mailbox: 'dispatch:d1', @@ -422,6 +423,7 @@ describe('forgetting one settled worker', () => { : null }, getCliCommand: () => 'orca', + senderName: () => null, host: { readSessionFacts: async () => (attached ? { submissions: [] } : null), currentFence: () => 4, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index f442c520d4bf..0683a7c22d91 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -13,7 +13,6 @@ */ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import type { MessageRow, OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' import type { OrchestrationCliCommand } from './cli-command' @@ -26,6 +25,7 @@ import { type StructuredPointerSubmission } from './structured-pointer-operation-id' import { structuredMailSource } from './structured-mail-source' +import type { SenderNameResolver } from './agent-message-sender' import { retainReasonForDispatch, structuredDispatchDelivered, @@ -67,8 +67,8 @@ export type StructuredMailboxPointerHost = { dispatchId: string | null operationId: string expectedRuntimeFence: number + /** Names its senders as `from`. */ body: AgentJournalMessageItem - source: AgentMessageSource }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null @@ -86,6 +86,8 @@ type StructuredPointerDeliveryDependencies StructuredPointerTarget | null /** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */ getCliCommand: () => OrchestrationCliCommand + /** What Orca calls a sender now, snapshotted onto the message; null when it has no name. */ + senderName: SenderNameResolver host: StructuredMailboxPointerHost onRetain?: (input: { mailboxHandle: string @@ -219,7 +221,14 @@ export class OrchestrationStructuredMailboxPointerDelivery< type: 'text', text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim() } - ] + ], + from: structuredMailSource({ + db, + mailboxHandle, + dispatchId: target.dispatchId, + batch: unread, + senderName: this.deps.senderName + }) } const staged = unread.map((message) => message.id) const operation = resolveStructuredPointerOperation({ @@ -247,13 +256,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< dispatchId: target.dispatchId, operationId: operation.operationId, expectedRuntimeFence: fence, - body, - source: structuredMailSource({ - db, - mailboxHandle, - dispatchId: target.dispatchId, - batch: unread - }) + body }) if (outcome.kind === 'unattached') { this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 1d1700e76bce..fb0b8313cfd5 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -121,7 +121,7 @@ describe('structured mailbox pointer host', () => { it('asks a busy chat to queue the pointer as a card, with who it is from', async () => { const send = vi.fn( - async (_caller: unknown, _payload: { delivery?: string; source?: unknown }) => ({ + async (_caller: unknown, _payload: { delivery?: string; body?: unknown }) => ({ ok: true, value: { clientMessageId: 'op1', @@ -136,13 +136,12 @@ describe('structured mailbox pointer host', () => { dispatchId: 'd1', operationId: 'op1', expectedRuntimeFence: 1, - body: { kind: 'message', role: 'user', blocks: [] }, - source: NOTICE_SOURCE + body: { kind: 'message', role: 'user', blocks: [], from: NOTICE_SOURCE } }) ).resolves.toEqual({ kind: 'queued' }) expect(send.mock.calls[0]![1]).toMatchObject({ delivery: 'queue-if-active', - source: NOTICE_SOURCE + body: { from: NOTICE_SOURCE } }) }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 7bb79d01e3e3..f81ef2c229f3 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -103,7 +103,6 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo body: input.body, // As a person's message is: a busy chat queues it as a card, sent when the turn ends. delivery: 'queue', - source: input.source, operationId: input.operationId, expectedRuntimeFence: input.expectedRuntimeFence } 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 55717cca96b5..8b0a81df6102 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 @@ -53,7 +53,7 @@ describe('committing a launch prompt', () => { 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' }) + expect(params.personsMessage).toBe(true) // 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 e18f557163db..05f1f8a7b3a2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -24,7 +24,6 @@ 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. @@ -58,7 +57,7 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { 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 + personsMessage: true }) return result.ok ? result.value.clientMessageId : null } catch (error) { diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index c8284dc4d108..e7d801f50be0 100644 --- a/src/main/runtime/rpc/methods/orchestration/caller-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/caller-show.ts @@ -1,5 +1,6 @@ import type { OrchestrationCallerShowResult, + OrchestrationPartyLocationResult, OrchestrationSessionAddressResult } from '../../../../../shared/orchestration-caller-status' import { @@ -7,9 +8,14 @@ import { isOrcaSessionId } from '../../../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../shared/orchestration-session-caller-codes' -import { SessionAddressParams } from '../../../../../shared/rpc-contract/orchestration-params' +import { + PartyLocationParams, + SessionAddressParams +} from '../../../../../shared/rpc-contract/orchestration-params' import { OrchestrationError } from '../../../orchestration/orchestration-error' import { resolveOrcaSessionParty } from '../../../orchestration/orchestration-party' +import { locateOrchestrationParty } from '../../../orchestration/orchestration-party-location' +import { readAgentSessionRecordStore } from '../../../orchestration/structured-session-lineage' import { defineMethod } from '../../core' export const ORCHESTRATION_CALLER_METHODS = [ @@ -39,5 +45,21 @@ export const ORCHESTRATION_CALLER_METHODS = [ const party = resolveOrcaSessionParty(params.sessionId, runtime.getOrchestrationDb()) return { orcaSessionId: formatOrcaSessionAddress(party.orcaSessionId) } } + }), + defineMethod({ + name: 'orchestration.partyLocation', + params: PartyLocationParams, + // Why host-side: a chat sender's live session follows its `/clear` lineage on this host. + handler: (params, { runtime }): OrchestrationPartyLocationResult => + locateOrchestrationParty( + params.address, + { + db: runtime.getOrchestrationDb(), + records: readAgentSessionRecordStore(), + terminalPaneKey: (handle) => runtime.getTerminalPaneKey(handle), + terminalHandleForPaneKey: (paneKey) => runtime.getTerminalHandleForPaneKey(paneKey) + }, + params.messageIds + ) }) ] diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index ddc5711c8274..0bdcbb0cbcc7 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -21,6 +21,7 @@ import { sessionRecord, WORKER_HANDLE, WORKER_PANE, + WORKSPACE_X, type SessionCallerHarness } from './orchestration-session-caller-test-fixture' @@ -106,7 +107,8 @@ describe('every target param resolves both spellings of a party to one canonical 'orchestration.ask', 'orchestration.dispatch', 'orchestration.inbox', - 'orchestration.sessionAddress' + 'orchestration.sessionAddress', + 'orchestration.partyLocation' ].sort() ) }) @@ -218,6 +220,49 @@ describe('every target param resolves both spellings of a party to one canonical orcaSessionId: ADDRESS_Y }) }) + + it.each(WORKER_SPELLINGS)( + 'partyLocation: a worker at %s opens as its chat', + async (_l, address) => { + expect(await as(undefined, 'orchestration.partyLocation', { address })).toEqual({ + location: { kind: 'chat', sessionId: SESSION_Y, worktreeId: WORKSPACE_X } + }) + } + ) + + it('partyLocation: a dispatch opens as its assignee, a terminal as itself, a lost one as nothing', async () => { + const dispatchId = assignWorker(await chatRun()) + expect( + await as(undefined, 'orchestration.partyLocation', { address: `dispatch:${dispatchId}` }) + ).toEqual({ location: { kind: 'chat', sessionId: SESSION_Y, worktreeId: WORKSPACE_X } }) + expect(await as(undefined, 'orchestration.partyLocation', { address: WORKER_HANDLE })).toEqual({ + location: { kind: 'terminal', handle: WORKER_HANDLE } + }) + expect( + await as(undefined, 'orchestration.partyLocation', { address: 'dispatch:gone' }) + ).toEqual({ location: null }) + }) + + it("partyLocation: a terminal handle from an earlier run opens as its pane's live terminal, by the mail it sent", async () => { + vi.spyOn(h.runtime, 'getTerminalHandleForPaneKey').mockImplementation((paneKey) => + paneKey === WORKER_PANE ? WORKER_HANDLE : null + ) + const mail = h.db.insertMessage({ + from: 'term_previous_run', + to: ADDRESS_X, + subject: 's', + senderPaneKey: WORKER_PANE + }) + expect( + await as(undefined, 'orchestration.partyLocation', { + address: 'term_previous_run', + messageIds: [mail.id] + }) + ).toEqual({ location: { kind: 'terminal', handle: WORKER_HANDLE } }) + expect( + await as(undefined, 'orchestration.partyLocation', { address: 'term_previous_run' }) + ).toEqual({ location: null, lost: 'terminal' }) + }) }) describe('a /clear-ed chat is shown the Orca session ID it had before the clear', () => { diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index 27414b3c1d74..38e26730a1e4 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -38,7 +38,14 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' })) // Fields that can name a party: the caller in ORCHESTRATION_CALLER_PARAM, a target in ORCHESTRATION_TARGET_PARAM. -const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle', 'sessionId'] as const +const PARTY_NAMING_FIELDS = [ + 'to', + 'from', + 'terminal', + 'callerTerminalHandle', + 'sessionId', + 'address' +] as const // `method field` pairs with such a field that is neither, so never resolves as a party. const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', @@ -95,9 +102,9 @@ describe('orchestration session callers at the dispatch entry', () => { }) .sort() - // The population: 43 registered methods carrying 27 party-naming fields. - expect(registry.size).toBe(43) - expect(partyNaming).toHaveLength(27) + // The population: 44 registered methods carrying 28 party-naming fields. + expect(registry.size).toBe(44) + expect(partyNaming).toHaveLength(28) expect(partyNaming).toEqual( [ ...Object.entries(ORCHESTRATION_CALLER_PARAM).map( diff --git a/src/main/runtime/runtime-orchestration-sender-names.test.ts b/src/main/runtime/runtime-orchestration-sender-names.test.ts new file mode 100644 index 000000000000..3958493f1d2d --- /dev/null +++ b/src/main/runtime/runtime-orchestration-sender-names.test.ts @@ -0,0 +1,207 @@ +// A sender's name snapshot, read from the sources production fills: dispatch tasks in the +// orchestration database, the chat tabs and terminal tabs of the workspace session this host +// mirrors, and the terminal's own pty. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { OrchestrationDb } from './orchestration/db' +import { createRootDispatch } from './orchestration/db/root-dispatch-test-fixture' +import { reconcileLifecycleMessage } from './orchestration/lifecycle-reconciliation' +import { RuntimeOrchestrationSenderNames } from './runtime-orchestration-sender-names' +import { structuredMailSource } from './orchestration/structured-mail-source' +import type { MessageRow } from './orchestration/types' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const WORKTREE = 'repo_1::/work/tree' + +type Session = NonNullable< + ReturnType< + ConstructorParameters[0]['getWorkspaceSession'] + > +> + +let db: OrchestrationDb +let session: Session +let records: Map + +function names() { + return new RuntimeOrchestrationSenderNames({ + getDb: () => db, + getHandleRecord: (handle) => + handle === 'term_worker' || handle === 'term_plain' + ? { worktreeId: WORKTREE, tabId: `tab_${handle}`, ptyId: `pty_${handle}` } + : undefined, + getPtyAgents: () => ({ launchAgent: 'codex' }), + getTerminalPaneKey: (handle) => `tab_${handle}:leaf`, + getWorkspaceSession: (worktreeId) => (worktreeId === WORKTREE ? session : undefined) + }) +} + +/** A worker dispatched `spec` on its own terminal, as `worker-start` leaves it. */ +function finishedWorker(spec: string, handle = 'term_worker') { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey: `tab_${handle}:leaf`, + processIncarnation: 'p:1', + worktreeId: WORKTREE, + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + return { runId: run.id, taskId: task.id, dispatchId: started.dispatch.id } +} + +/** Mail from `term_worker`, from its pane; a report names its task and dispatch. */ +function workerMessage( + runId: string, + type: 'worker_done' | 'status', + report?: { taskId: string; dispatchId: string } +) { + return db.insertMessage({ + from: 'term_worker', + to: `run:${runId}`, + subject: type, + type, + senderPaneKey: 'tab_term_worker:leaf', + runId, + ...(report ? { payload: JSON.stringify({ ...report, outcome: 'succeeded' }) } : {}) + }) +} + +/** The name the mail lane records for `term_worker` when it announces these messages. */ +function announcedName(batch: MessageRow[]): string | null { + const sources = names() + return ( + structuredMailSource({ + db, + mailboxHandle: 'run:r', + dispatchId: null, + batch, + senderName: (party, reported) => sources.nameOf(party, reported) + }).senders[0]?.name ?? null + ) +} + +function chatTab(customLabel: string | null, label: string) { + return { contentType: 'agent-session' as const, entityId: CHAT, customLabel, label } +} + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + session = { unifiedTabs: {}, tabsByWorktree: {} } + const base = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: CHAT })) + records = new Map([[CHAT, { ...base, location: { ...base.location, workspaceId: WORKTREE } }]]) + hostRef.current = { + deps: { + store: { getRecord: (id: string) => records.get(id) ?? null, listRecords: () => [] } + } + } +}) + +afterEach(() => { + db.close() +}) + +const chatParty = { address: `orca_session_id:${CHAT}`, terminalHandle: null, orcaSessionId: CHAT } +const terminalParty = (handle: string) => ({ + address: handle, + terminalHandle: handle, + orcaSessionId: null +}) + +describe("a sender's name, from what Orca shows for it", () => { + it("names a local worker by its active dispatch's task, before its tab or agent", () => { + const run = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: null + }) + const task = db.createTask({ + runId: run.id, + spec: 'Port the parser', + displayName: 'Parser port' + }) + createRootDispatch(db, task.id, 'term_worker') + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_worker', customTitle: 'Build tab' }] } + expect(names().nameOf(terminalParty('term_worker'))).toBe('Parser port') + }) + + it('names a worker by its task after its own accepted worker_done settled that dispatch', () => { + const { runId, taskId, dispatchId } = finishedWorker('build it') + const report = workerMessage(runId, 'worker_done', { taskId, dispatchId }) + // Settled synchronously at send, before the mail lane names who it is from. + expect(reconcileLifecycleMessage(db, report).action).toBe('completed') + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') + expect(announcedName([report])).toBe('build it') + }) + + it('never names a worker by a task it never ran, nor by work it finished long ago', () => { + const { runId, taskId, dispatchId } = finishedWorker('task A') + const report = workerMessage(runId, 'worker_done', { taskId, dispatchId }) + expect(reconcileLifecycleMessage(db, report).action).toBe('completed') + // Its next message, unrelated to that finished task: no task names it. + expect(announcedName([workerMessage(runId, 'status')])).toBe('Codex') + // A second task dispatched to the same terminal fails before the worker runs it. + const taskB = db.createTask({ runId, spec: 'task B' }) + const failed = createRootDispatch(db, taskB.id, 'term_worker') + db.failDispatch(failed.id, 'delivery failed') + expect(announcedName([workerMessage(runId, 'status')])).toBe('Codex') + }) + + it('never names a worker by a dispatch its report names that is not its own', () => { + const other = finishedWorker('someone else', 'term_other') + expect(announcedName([workerMessage(other.runId, 'worker_done', other)])).toBe('Codex') + }) + + it("names a chat by the label its tab shows, the person's rename first", () => { + session.unifiedTabs = { [WORKTREE]: [chatTab(null, 'Fix the login flow')] } + expect(names().nameOf(chatParty)).toBe('Fix the login flow') + session.unifiedTabs = { [WORKTREE]: [chatTab('Auth chat', 'Fix the login flow')] } + expect(names().nameOf(chatParty)).toBe('Auth chat') + }) + + it("names a chat with no tab by its agent's chat label", () => { + expect(names().nameOf(chatParty)).toBe('Claude Chat') + }) + + it("names a terminal by its tab's stored title, else its agent", () => { + expect(names().nameOf(terminalParty('term_plain'))).toBe('Codex') + session.tabsByWorktree = { [WORKTREE]: [{ id: 'tab_term_plain', customTitle: ' Lint ' }] } + expect(names().nameOf(terminalParty('term_plain'))).toBe('Lint') + }) + + it('names a party nothing records as nothing', () => { + expect(names().nameOf(terminalParty('term_unknown'))).toBeNull() + }) + + it('builds the one sender a message records: its party and the name it has now', () => { + session.unifiedTabs = { [WORKTREE]: [chatTab(null, 'Fix the login flow')] } + expect(names().sender(`orca_session_id:${CHAT}`)).toEqual({ + party: { address: `orca_session_id:${CHAT}`, terminalHandle: null, orcaSessionId: CHAT }, + name: 'Fix the login flow' + }) + }) +}) diff --git a/src/main/runtime/runtime-orchestration-sender-names.ts b/src/main/runtime/runtime-orchestration-sender-names.ts new file mode 100644 index 000000000000..ed0c167cf6c4 --- /dev/null +++ b/src/main/runtime/runtime-orchestration-sender-names.ts @@ -0,0 +1,83 @@ +// The runtime's answer to "what is this agent called", from its own records, for any send that +// speaks for another agent: the mail lane today, a dispatched task next. + +import type { AgentMessageSender } from '../../shared/agent-session-message-source' +import type { AgentType } from '../../shared/agent-status-types' +import type { Tab } from '../../shared/tab-types' +import type { TerminalTab } from '../../shared/terminal-tab-types' +import type { OrchestrationDb } from './orchestration/db' +import { + orchestrationSenderName, + type TerminalSenderNaming +} from './orchestration/orchestration-sender-name' +import { agentMessageSender } from './orchestration/agent-message-sender' +import { readAgentSessionRecordStore } from './orchestration/structured-session-lineage' + +type RuntimeSenderNameDeps = { + getDb: () => OrchestrationDb | null + getHandleRecord: ( + handle: string + ) => { worktreeId: string; tabId: string; ptyId: string | null } | undefined + getPtyAgents: ( + ptyId: string + ) => { launchAgent?: AgentType | null; foregroundAgent?: AgentType | null } | undefined + getTerminalPaneKey: (handle: string) => string | null + /** The workspace session this host mirrors, of which only the tabs are read. */ + getWorkspaceSession: (worktreeId: string) => MirroredTabs | null | undefined +} + +type MirroredTabs = { + unifiedTabs?: Readonly< + Record[]> + > + tabsByWorktree?: Readonly[]>> +} + +export class RuntimeOrchestrationSenderNames { + constructor(private readonly deps: RuntimeSenderNameDeps) {} + + /** A sender recorded on a message, named now. `reportedDispatchId`: the dispatch the sender's + * own `worker_done` in that message names. */ + sender(address: string, reportedDispatchId?: string): AgentMessageSender { + return agentMessageSender( + address, + this.deps.getDb(), + (party, reported) => this.nameOf(party, reported), + reportedDispatchId + ) + } + + nameOf(party: AgentMessageSender['party'], reportedDispatchId?: string): string | null { + return orchestrationSenderName( + party, + { + db: this.deps.getDb(), + records: readAgentSessionRecordStore(), + chatTab: (worktreeId, sessionId) => + this.deps + .getWorkspaceSession(worktreeId) + ?.unifiedTabs?.[worktreeId]?.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId + ) ?? null, + terminal: (handle) => this.terminalNaming(handle) + }, + reportedDispatchId + ) + } + + private terminalNaming(handle: string): TerminalSenderNaming | null { + const record = this.deps.getHandleRecord(handle) + if (!record) { + return null + } + const pty = record.ptyId ? this.deps.getPtyAgents(record.ptyId) : undefined + const tab = this.deps + .getWorkspaceSession(record.worktreeId) + ?.tabsByWorktree?.[record.worktreeId]?.find((candidate) => candidate.id === record.tabId) + return { + customTitle: tab?.customTitle ?? null, + agent: pty?.launchAgent ?? pty?.foregroundAgent ?? null, + paneKey: this.deps.getTerminalPaneKey(handle) + } + } +} diff --git a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts index 104f767e333f..22af4ddb2a16 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-queue.test.ts @@ -70,7 +70,9 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() const endTurn = await runningUserTurn(chat) - await finishWorker(taskId) + const dispatchId = await finishWorker(taskId) + // The report was accepted, so its dispatch settled before its mail was named. + expect(db.getDispatchContextById(dispatchId)?.status).toBe('completed') await vi.waitFor( async () => expect(await queuedCardTexts()).toEqual([ptyPointer(`run:${runId}`)]), WAIT @@ -78,10 +80,14 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { expect(chat.turns).toHaveLength(1) const [card] = queuedRows() const [mail] = db.getAllMessages(`run:${runId}`) - expect(card?.source).toEqual({ + const from = { kind: 'agent', senders: [ - { party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null } } + { + party: { address: 'term_worker', terminalHandle: 'term_worker', orcaSessionId: null }, + // Named by the task of the dispatch it just finished, through the real runtime's naming. + name: 'build it' + } ], orchestration: { message: 'mail-notice', @@ -89,11 +95,18 @@ describe("a busy chat's orchestration pointer waits in its queue", () => { dispatchId: null, messages: [{ messageId: mail!.id, runId, from: 'term_worker' }] } - }) + } + // On the card's body: the turn the queue sends carries it, and the provider never sees it. + expect(card?.body.from).toEqual(from) await endTurn() await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) expect(turnText(chat.turns[1]!)).toBe(ptyPointer(`run:${runId}`)) + expect(JSON.stringify(chat.turns[1])).not.toContain('term_worker') + const sent = (await host.journalSnapshot(COORDINATOR)).items.filter( + (item) => item.body.kind === 'message' && item.body.from + ) + expect(sent.map((item) => item.body.kind === 'message' && item.body.from)).toEqual([from]) expect(await queuedCardTexts()).toEqual([]) await settleTurn(COORDINATOR, 1) await idleEdgesSettled() diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index fc5928df20b6..8aca8f498038 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -157,11 +157,11 @@ export async function userTexts(sessionId: string): Promise { ) } -/** A supervised terminal worker under the coordinator's Run, and its worker_done. */ +/** A supervised terminal worker under the coordinator's Run, and its worker_done; its dispatch id. */ export async function finishWorker( taskId: string, worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } -): Promise { +): Promise { const started = db.createStartingWorkerDispatch({ creator: { kind: 'system' }, maxDepth: Number.MAX_SAFE_INTEGER, @@ -184,6 +184,7 @@ export async function finishWorker( type: 'worker_done', payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) }) + return started.dispatch.id } export async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { @@ -273,6 +274,10 @@ export function startRuntime(): OrcaRuntimeService { vi.spyOn(started, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null ) + // The exact process `finishWorker` records, so a worker's report is accepted and settles. + vi.spyOn(started, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' || handle === 'term_worker_2' ? `runtime_test:${handle}:1` : null + ) return started } diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx new file mode 100644 index 000000000000..86fcf30a2cbe --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageRow.test.tsx @@ -0,0 +1,162 @@ +// @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 { AgentMessageSource } from '../../../../shared/agent-session-message-source' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { MessageRow } from './NativeChatMessageRow' +import { NativeChatQueuedMessageCard } from './NativeChatQueuedMessageCard' +import { TooltipProvider } from '@/components/ui/tooltip' +import { queuedCardSenderLine } from './native-chat-agent-message-sender-label' + +const openAgentMessageSender = vi.hoisted(() => vi.fn(async () => {})) +vi.mock('@/lib/open-agent-message-sender', () => ({ openAgentMessageSender })) + +afterEach(() => { + cleanup() + openAgentMessageSender.mockClear() +}) + +const CODER = { address: 'term_coder', terminalHandle: 'term_coder', orcaSessionId: null } + +function from(senders: { address: string; name: string | null }[]): AgentMessageSource { + return { + kind: 'agent', + senders: senders.map(({ address, name }) => ({ + party: { address, terminalHandle: address, orcaSessionId: null }, + name + })), + orchestration: null + } +} + +function renderUserMessage(source?: AgentMessageSource, worktreeId: string | null = 'wt-chat') { + const message: NativeChatMessage = { + id: 'message', + role: 'user', + timestamp: 0, + source: 'transcript', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }], + ...(source ? { from: source } : {}) + } + return render( + + ) +} + +describe("another agent's message in the transcript", () => { + it('reads as a left-aligned message from its sender, not as the person’s bubble', () => { + const { container } = renderUserMessage(from([{ address: CODER.address, name: 'Coder' }])) + expect(screen.getByText('Message from')).toBeInTheDocument() + const row = container.firstElementChild + expect(row).toHaveClass('items-start') + expect(row).not.toHaveClass('items-end') + expect(container.querySelector('.bg-chat-user-surface')).toBeNull() + expect(screen.getByText('You have 1 orchestration message.')).toBeInTheDocument() + }) + + it("opens the sender from its name, on the chat's own host, with the message it sent", () => { + const source = from([{ address: CODER.address, name: 'Coder' }]) + renderUserMessage(source) + fireEvent.click(screen.getByRole('button', { name: 'Coder' })) + expect(openAgentMessageSender).toHaveBeenCalledWith(source, source.senders[0], 'wt-chat') + }) + + it('separates the names as the queued card does, and keeps two same-named senders two', () => { + const { container } = renderUserMessage( + from([ + { address: 'term_a', name: 'Codex' }, + { address: 'term_b', name: 'Codex' } + ]) + ) + expect(screen.getAllByRole('button', { name: 'Codex' })).toHaveLength(2) + expect(container.textContent).toContain('Message fromCodex,Codex') + }) + + it('names each sender, an unnamed one as an agent, and counts the rest', () => { + renderUserMessage( + from([ + { address: 'term_a', name: 'A' }, + { address: 'term_b', name: null }, + { address: 'term_c', name: 'C' }, + { address: 'term_d', name: 'D' } + ]) + ) + expect(screen.getByRole('button', { name: 'A' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'an agent' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'C' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'D' })).not.toBeInTheDocument() + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('shows a sender on another host as plain text, since this host cannot open it', () => { + renderUserMessage( + from([ + { address: 'dispatch:d1', name: 'Port the parser' }, + { address: CODER.address, name: 'Coder' } + ]) + ) + expect(screen.getByText('Port the parser')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Port the parser' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Coder' })).toBeInTheDocument() + }) + + it('shows the name as plain text where there is no chat to open it from', () => { + renderUserMessage(from([{ address: CODER.address, name: 'Coder' }]), null) + expect(screen.getByText('Coder')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Coder' })).not.toBeInTheDocument() + }) + + it("leaves the person's own message as their right-aligned bubble", () => { + const { container } = renderUserMessage() + expect(screen.queryByText('Message from')).not.toBeInTheDocument() + expect(container.firstElementChild).toHaveClass('items-end') + expect(container.querySelector('.bg-chat-user-surface')).not.toBeNull() + }) +}) + +describe("another agent's queued card", () => { + it('names who it is from on a line the card’s own controls keep their clicks from', () => { + render( + + + + ) + expect(screen.getByText('From Coder, an agent')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Coder/ })).not.toBeInTheDocument() + }) + + it('counts in "+N" only the senders it does not name, same-named ones included', () => { + expect( + queuedCardSenderLine( + from( + ['term_a', 'term_b', 'term_c', 'term_d'].map((address) => ({ address, name: 'Codex' })) + ) + ) + ).toBe('From Codex, Codex, Codex +1') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx new file mode 100644 index 000000000000..463e1f2c354d --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAgentMessageSenders.tsx @@ -0,0 +1,61 @@ +import { Fragment } from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { openAgentMessageSender } from '@/lib/open-agent-message-sender' +import { + agentMessageSendersShown, + type AgentMessageSource +} from '../../../../shared/agent-session-message-source' +import { agentMessageSenderLabel } from './native-chat-agent-message-sender-label' + +/** "Message from " over another agent's message; each name opens that agent. Plain text + * where the transcript has no chat to resolve the sender against, and for a sender that runs on + * another host. */ +export function NativeChatAgentMessageSenders({ + from, + chatWorktreeId +}: { + from: AgentMessageSource + chatWorktreeId: string | null +}): React.JSX.Element { + const { shown, more } = agentMessageSendersShown(from) + return ( +
+ {translate('components.native-chat.agentMessage.messageFrom', 'Message from')} + {shown.length === 0 ? ( + + {translate('components.native-chat.agentMessage.unnamedSender', 'an agent')} + + ) : null} + {shown.map((sender, index) => ( + // Fragment keys: one sender's name and the separator after it. + + {chatWorktreeId && opensFromHere(sender) ? ( + + ) : ( + {agentMessageSenderLabel(sender)} + )} + {/* Pulled back over the name's padding, so it reads "A, B" as the queued card does. */} + {index < shown.length - 1 ? ( + + , + + ) : null} + + ))} + {more > 0 ? +{more} : null} +
+ ) +} + +/** A federated sender (`dispatch:`) runs on another host, which this chat's host cannot open. */ +function opensFromHere(sender: AgentMessageSource['senders'][number]): boolean { + return !sender.party.address.startsWith('dispatch:') +} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 8bd429c51da4..7c2418cf6f79 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -19,6 +19,7 @@ import { NativeChatNoticeRow } from './NativeChatNoticeRow' import { nativeChatBlocksInOwnWords } from './native-chat-stopped-before-start-row' import { NativeChatCopyButton } from './NativeChatCopyButton' import { NativeChatMessageTimestamp } from './NativeChatMessageTimestamp' +import { NativeChatAgentMessageSenders } from './NativeChatAgentMessageSenders' import { NativeChatAgentControls, NativeChatImageAttachments, @@ -179,10 +180,29 @@ export const MessageRow = memo(function MessageRow({ } if (isUser) { + // Another agent's message is the agent's turn input too, but is not the person's: it reads + // left-aligned under its sender rather than as their bubble. + const from = message.from return ( -
+
+ {from ? ( + + ) : null} {/* A distinct surface separates the user's prompt from the assistant's prose. */} -
+
{markdown ? ( <> )}
+ {card.from ? ( +

+ {queuedCardSenderLine(card.from)} +

+ ) : null}

{card.text}

diff --git a/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts new file mode 100644 index 000000000000..a2e44282382a --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-agent-message-sender-label.ts @@ -0,0 +1,25 @@ +import { translate } from '@/i18n/i18n' +import { + agentMessageSendersShown, + type AgentMessageSender, + type AgentMessageSource +} from '../../../../shared/agent-session-message-source' + +function unnamedSenderLabel(): string { + return translate('components.native-chat.agentMessage.unnamedSender', 'an agent') +} + +export function agentMessageSenderLabel(sender: AgentMessageSender): string { + return sender.name ?? unnamedSenderLabel() +} + +/** A queued card's plain "From " line; the card's own controls own its clicks. */ +export function queuedCardSenderLine(from: AgentMessageSource): string { + const { shown, more } = agentMessageSendersShown(from) + // One name per sender, repeats included: two senders that share a name are still two. + const listed = + shown.length > 0 ? shown.map(agentMessageSenderLabel).join(', ') : unnamedSenderLabel() + return translate('components.native-chat.queuedMessages.from', 'From {{names}}', { + names: more > 0 ? `${listed} +${more}` : listed + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts b/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts index b9bf366fcd0e..a0bf44185bec 100644 --- a/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts +++ b/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts @@ -11,7 +11,8 @@ export function nativeChatRewindOffered(support: AgentSessionRewindSupport | und /** A sent prompt that opened its own turn, outside any subagent's section. Codex rewinds whole * turns, so a steer would also discard its turn's opener; unsent, queued, command and goal rows - * have no turn to go back to. An image with no local file could not go back to the composer. */ + * have no turn to go back to. An image with no local file could not go back to the composer, and + * another agent's message is not the person's to take back into their composer. */ export function nativeChatRowOffersRewind( message: NativeChatMessage, slot: { depth: number; turnKey: string | undefined }, @@ -25,6 +26,7 @@ export function nativeChatRowOffersRewind( message.queued !== true && message.command === undefined && message.sentAs === undefined && + message.from === undefined && message.blocks.every((block) => block.type !== 'image-ref' || Boolean(block.path)) ) } 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 4dc58abf4908..986b8eb0981b 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 @@ -246,3 +246,25 @@ describe('queued message cards', () => { expect(ids(outboxOutsideQueuedCards(sent, [], true, unknown))).toEqual(['plain']) }) }) + +describe("another agent's card", () => { + it('carries who it is from, read through the shared reader', () => { + const from = { + kind: 'agent' as const, + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + const agentDraft = draft('a', 1) + const cards = projectQueuedMessageCards( + [{ ...agentDraft, body: { ...agentDraft.body, from } }, draft('b', 2)], + [], + { hasPendingPrompt: false } + ) + expect(cards.map((card) => card.from)).toEqual([from, undefined]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts index e9d1f394b9af..f49f3e32c1d4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts @@ -4,6 +4,10 @@ import type { UnreadAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-wire' +import { + readAgentMessageSource, + type AgentMessageSource +} from '../../../../shared/agent-session-message-source' import { handedOffQueuedMessageIds } from '../../../../shared/structured-agent-session-draft-hand-off' import { structuredAgentSessionEntryAsksToQueue, @@ -37,6 +41,8 @@ export type QueuedMessageCard = { returnedReason?: string | null /** The typed fact the returned card's submission settled with; read like its `rejection`. */ returnedRejection?: UnreadAgentSessionFailureFact + /** Another agent's card: who sent it. */ + from?: AgentMessageSource } function queuedMessageCardText(body: AgentSessionQueuedMessage['body']): string { @@ -76,6 +82,7 @@ export function projectQueuedMessageCards( ? 'awaiting-answer' : 'turn' behindReturned = behindReturned || message.state === 'returned' + const from = readAgentMessageSource(message.body.from) return { messageId: message.messageId, position: message.position, @@ -86,7 +93,8 @@ export function projectQueuedMessageCards( ...(message.returnedReason !== undefined ? { returnedReason: message.returnedReason } : {}), ...(message.returnedRejection !== undefined ? { returnedRejection: message.returnedRejection } - : {}) + : {}), + ...(from ? { from } : {}) } }) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx index 16a59098f778..b30730b2056d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx @@ -447,6 +447,23 @@ describe('which rows offer rewind', () => { ['a queued row', message({ queued: true }), opens, false], ['a /compact row', message({ command: { name: 'compact' } }), opens, false], ['a goal', message({ sentAs: 'goal' }), opens, false], + [ + "another agent's message, which is not the person's to take back", + message({ + from: { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null + } + }), + opens, + false + ], [ 'a prompt with a URL image, which could not go back to the composer', message({ blocks: [{ type: 'image-ref', url: 'https://example.com/shot.png' }] }), diff --git a/src/renderer/src/components/terminal-pane/stale-agent-row.ts b/src/renderer/src/components/terminal-pane/stale-agent-row.ts index b781f76940d2..3842010d3eee 100644 --- a/src/renderer/src/components/terminal-pane/stale-agent-row.ts +++ b/src/renderer/src/components/terminal-pane/stale-agent-row.ts @@ -10,22 +10,27 @@ export function dismissStaleAgentRowByKey(paneKey: string): void { store.dropAgentStatus(paneKey, { paneRemoved: true }) store.dismissRetainedAgent(paneKey) if (liveExisted || retainedExisted) { - toast.info( + showAgentPaneUnavailable( translate( - 'auto.components.terminal.pane.stale.agent.row.ad991ece5c', - "Agent's pane is no longer available." - ), - { - id: translate( - 'auto.components.terminal.pane.stale.agent.row.090d607412', - 'stale-agent-row-{{value0}}', - { value0: paneKey } - ) - } + 'auto.components.terminal.pane.stale.agent.row.090d607412', + 'stale-agent-row-{{value0}}', + { value0: paneKey } + ) ) } } +/** The one notice for a click that reaches an agent whose pane is gone. */ +export function showAgentPaneUnavailable(id?: string): void { + toast.info( + translate( + 'auto.components.terminal.pane.stale.agent.row.ad991ece5c', + "Agent's pane is no longer available." + ), + id === undefined ? undefined : { id } + ) +} + export function surfaceStaleAgentRow(tabId: string, leafId: string): void { dismissStaleAgentRowByKey(makePaneKey(tabId, leafId)) } diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts index ab210f163525..34c8175faf6a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.ts @@ -298,7 +298,7 @@ function getTerminalHandleFocusHint(showActions: boolean): string { : `${prefix}Ctrl+click to switch terminal` } -async function focusRuntimeTerminalHandle( +export async function focusRuntimeTerminalHandle( handle: string, runtimeEnvironmentId: string | null ): Promise { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 217d3e489de9..ab40a3656bd6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18291,6 +18291,10 @@ "used": "Context: {{used}} tokens used, estimated from the last response.", "unreported": "Context usage is not available for this session." }, + "agentMessage": { + "messageFrom": "Message from", + "unnamedSender": "an agent" + }, "goal": { "placeholder": "Describe your goal, define measurable outcomes for best results", "clear": "Clear goal", @@ -18349,7 +18353,8 @@ "queuePausedRestarted": "Queue paused because Orca restarted", "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", - "resume": "Resume" + "resume": "Resume", + "from": "From {{names}}" }, "thought": "Thought", "thoughtForDuration": "Thought for {{duration}}", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b514fe3ed619..5d9fe87229cc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17971,7 +17971,8 @@ "queuePausedRestarted": "Cola en pausa porque Orca se reinició", "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", - "resume": "Reanudar" + "resume": "Reanudar", + "from": "De {{names}}" }, "thought": "Pensó", "thoughtForDuration": "Pensó durante {{duration}}", @@ -18084,6 +18085,10 @@ "used": "Contexto: {{used}} tokens usados, estimado a partir de la última respuesta.", "unreported": "El uso del contexto no está disponible para esta sesión." }, + "agentMessage": { + "messageFrom": "Mensaje de", + "unnamedSender": "un agente" + }, "goal": { "placeholder": "Describe tu objetivo y define resultados medibles para obtener mejores resultados", "clear": "Borrar objetivo", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 15f62be726ea..f5c657e6d253 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18145,6 +18145,10 @@ "used": "Contexte : {{used}} tokens utilisés, estimation basée sur la dernière réponse.", "unreported": "L'utilisation du contexte n'est pas disponible pour cette session." }, + "agentMessage": { + "messageFrom": "Message de", + "unnamedSender": "un agent" + }, "goal": { "placeholder": "Décrivez votre objectif, définissez des résultats mesurables pour de meilleurs résultats", "clear": "Objectif clair", @@ -18197,7 +18201,8 @@ "queuePausedRestarted": "File d'attente en pause, car Orca a redémarré", "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", "queuePaused": "File d'attente en pause", - "resume": "Reprendre" + "resume": "Reprendre", + "from": "De {{names}}" }, "thought": "A réfléchi", "thoughtForDuration": "A réfléchi pendant {{duration}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f672aec8d265..e9f9c9e8c173 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18079,6 +18079,10 @@ "used": "コンテキスト: {{used}} トークン使用済み、最後の応答から推定。", "unreported": "このセッションではコンテキストの使用量を表示できません。" }, + "agentMessage": { + "messageFrom": "送信元:", + "unnamedSender": "Agent" + }, "goal": { "placeholder": "目標を説明し、最良の結果を得るために測定可能な結果を​​定義する", "clear": "明確な目標", @@ -18131,7 +18135,8 @@ "queuePausedRestarted": "Orca が再起動したため、キューを一時停止しました", "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", - "resume": "再開" + "resume": "再開", + "from": "送信元: {{names}}" }, "thought": "考えました", "thoughtForDuration": "{{duration}} 考えました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 14acdb7cf295..a44ed6c6fd24 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18079,6 +18079,10 @@ "used": "컨텍스트: {{used}} 토큰 사용, 마지막 응답 기준 추정치입니다.", "unreported": "이 세션에서는 컨텍스트 사용량을 확인할 수 없습니다." }, + "agentMessage": { + "messageFrom": "보낸 사람:", + "unnamedSender": "Agent" + }, "goal": { "placeholder": "목표를 설명하고, 최상의 결과를 위해 측정 가능한 결과를 정의하세요.", "clear": "명확한 목표", @@ -18131,7 +18135,8 @@ "queuePausedRestarted": "Orca가 다시 시작되어 대기열이 일시 중지되었습니다", "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", - "resume": "재개" + "resume": "재개", + "from": "보낸 사람: {{names}}" }, "thought": "생각함", "thoughtForDuration": "{{duration}} 동안 생각함", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d5fb4a8214d0..5ca74326af4a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18044,6 +18044,10 @@ "used": "上下文:已用 {{used}} token,根据上一次响应的估算值。", "unreported": "此会话无法提供上下文用量。" }, + "agentMessage": { + "messageFrom": "消息来自", + "unnamedSender": "一个智能体" + }, "goal": { "placeholder": "描述您的目标,定义可衡量的结果以获得最佳结果", "clear": "明确的目标", @@ -18096,7 +18100,8 @@ "queuePausedRestarted": "因 Orca 重新启动,队列已暂停", "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", - "resume": "继续" + "resume": "继续", + "from": "来自 {{names}}" }, "thought": "已思考", "thoughtForDuration": "思考了 {{duration}}", diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts index c04378e0a930..becd74facb61 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -44,10 +44,12 @@ type StructuredSessionActivationDeps = { hostCannotOpen: () => void } -const defaultDeps: StructuredSessionActivationDeps = { - activate: activateStructuredAgentSessionById, - refresh: refreshStructuredSessionTabs, - reveal: revealStructuredSession, +/** The feedback a click that opens a chat gives when it cannot, shared by every surface that + * opens one. */ +export const structuredSessionOpenFeedback: Pick< + StructuredSessionActivationDeps, + 'unavailable' | 'gone' | 'hostCannotOpen' +> = { unavailable: () => { toast.error( translate( @@ -81,6 +83,13 @@ const defaultDeps: StructuredSessionActivationDeps = { } } +const defaultDeps: StructuredSessionActivationDeps = { + activate: activateStructuredAgentSessionById, + refresh: refreshStructuredSessionTabs, + reveal: revealStructuredSession, + ...structuredSessionOpenFeedback +} + /** * @param session anything carrying the row's structured pointer — an Agent Session History row or * the drag payload built from one. Only `structuredSession` is read, and both surfaces must reach diff --git a/src/renderer/src/lib/open-agent-message-sender.test.ts b/src/renderer/src/lib/open-agent-message-sender.test.ts new file mode 100644 index 000000000000..7dffd495e96e --- /dev/null +++ b/src/renderer/src/lib/open-agent-message-sender.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type TabsByWorktree = Record + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + focusRenderer: vi.fn(), + focusRuntime: vi.fn(), + paneUnavailable: vi.fn(), + activateChat: vi.fn(async () => true), + gone: vi.fn(), + hostCannotOpen: vi.fn(), + unavailable: vi.fn(), + toastError: vi.fn(), + tabs: new Map([['current', {}]]) +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string + }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) +})) +vi.mock('@/components/terminal-pane/terminal-handle-links', () => ({ + focusRendererTerminalHandle: mocks.focusRenderer, + focusRuntimeTerminalHandle: mocks.focusRuntime +})) +vi.mock('@/components/terminal-pane/stale-agent-row', () => ({ + showAgentPaneUnavailable: mocks.paneUnavailable +})) +vi.mock('./activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: mocks.activateChat, + structuredSessionOpenFeedback: { + gone: mocks.gone, + hostCannotOpen: mocks.hostCannotOpen, + unavailable: mocks.unavailable + } +})) +vi.mock('./worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => 'env-host' +})) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ unifiedTabsByWorktree: mocks.tabs.get('current') }) } +})) + +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { RUNTIME_COMPAT_BLOCK_CODE } from '@/runtime/runtime-protocol-compat' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../shared/agent-session-write-notice-copy' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { openAgentMessageSender } from './open-agent-message-sender' + +const HOST = { kind: 'environment', environmentId: 'env-host' } +const ROOT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const chat = { address: `orca_session_id:${ROOT}`, terminalHandle: null, orcaSessionId: ROOT } +const terminal = { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null } +const dispatch = { address: 'dispatch:d1', terminalHandle: 'dispatch:d1', orcaSessionId: null } + +/** A notice carrying mail from each party: m1 from the terminal, m2 from someone else. */ +function sourceFor(party: typeof chat | typeof terminal | typeof dispatch): AgentMessageSource { + return { + kind: 'agent', + senders: [{ party, name: 'Sender' }], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [ + { messageId: 'm1', runId: 'r1', from: party.address }, + { messageId: 'm2', runId: 'r1', from: 'term_other' } + ] + } + } +} + +function open(party: typeof chat | typeof terminal | typeof dispatch) { + const source = sourceFor(party) + return openAgentMessageSender(source, source.senders[0]!, 'wt-chat') +} + +function failure(code: string): RuntimeRpcCallError { + return new RuntimeRpcCallError({ + id: 'rpc_1', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime_1' } + }) +} + +function versionBlock(): Error { + return Object.assign(new Error('update needed'), { code: RUNTIME_COMPAT_BLOCK_CODE }) +} + +// Existing sentences, each translated whole: the cause and what to do, or which Orca to update. +const UNREACHABLE = `${AGENT_SESSION_WRITE_NOTICE_COPY.unreachable} ${AGENT_SESSION_WRITE_NOTICE_COPY.tryAgain}` +const UPDATE = AGENT_SESSION_WRITE_NOTICE_COPY.updateOrcaToOpenChat +const OLDER_HOST = AGENT_SESSION_WRITE_NOTICE_COPY.unsupported + +beforeEach(() => { + vi.clearAllMocks() + mocks.tabs.set('current', {}) +}) + +describe("opening a message's sender", () => { + it("asks the chat's host where a terminal is, with the mail it sent, and focuses what it finds", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_now' } }) + mocks.focusRenderer.mockReturnValue(true) + await open(terminal) + // The stored handle may be from an earlier run; only the host knows its live one. + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(HOST, 'orchestration.partyLocation', { + address: 'term_a', + messageIds: ['m1'] + }) + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_now', 'env-host') + expect(mocks.focusRuntime).not.toHaveBeenCalled() + }) + + it('says the pane is gone only when the terminal answer proves it', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_a' } }) + mocks.focusRenderer.mockReturnValue(false) + for (const code of [ + 'terminal_exited', + 'terminal_gone', + 'terminal_handle_stale', + 'terminal_not_found' + ]) { + mocks.focusRuntime.mockRejectedValueOnce(failure(code)) + await open(terminal) + } + expect(mocks.paneUnavailable).toHaveBeenCalledTimes(4) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + + it("says the host could not be reached, or needs updating, when the terminal's host does not answer", async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'terminal', handle: 'term_a' } }) + mocks.focusRenderer.mockReturnValue(false) + mocks.focusRuntime.mockRejectedValueOnce(failure('request_timeout')) + await open(terminal) + mocks.focusRuntime.mockRejectedValueOnce(versionBlock()) + await open(terminal) + expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UPDATE]) + expect(mocks.paneUnavailable).not.toHaveBeenCalled() + expect(mocks.unavailable).not.toHaveBeenCalled() + }) + + it('opens a chat sender at its live session through the open-chat flow', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + location: { kind: 'chat', sessionId: 'live-session', worktreeId: 'wt-sender' } + }) + await open(chat) + expect(mocks.activateChat).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'wt-sender', sessionId: 'live-session' } + }) + }) + + it('names what the host lost, not how the sender was addressed', async () => { + // A dispatch whose assignee was a chat gets the chat's words; one whose assignee was a pane, the pane's. + mocks.callRuntimeRpc.mockResolvedValueOnce({ location: null, lost: 'chat' }) + await open(dispatch) + mocks.callRuntimeRpc.mockResolvedValueOnce({ location: null, lost: 'terminal' }) + await open(dispatch) + expect(mocks.gone).toHaveBeenCalledTimes(1) + expect(mocks.paneUnavailable).toHaveBeenCalledTimes(1) + expect(mocks.activateChat).not.toHaveBeenCalled() + }) + + it('says the host could not be reached, or needs updating, when the lookup fails, for any sender', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('socket closed')) + await open(terminal) + mocks.callRuntimeRpc.mockRejectedValueOnce(versionBlock()) + await open(chat) + expect(mocks.toastError.mock.calls.map(([text]) => text)).toEqual([UNREACHABLE, UPDATE]) + expect(mocks.unavailable).not.toHaveBeenCalled() + expect(mocks.gone).not.toHaveBeenCalled() + }) + + it('treats a location kind a newer host answers with as needing an update, of either side', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: { kind: 'browser', url: 'x' } }) + await open(terminal) + expect(mocks.toastError).toHaveBeenCalledWith(UPDATE) + expect(mocks.focusRenderer).not.toHaveBeenCalled() + }) + + it('on a host before the lookup, opens what this window can, else asks for an update', async () => { + mocks.callRuntimeRpc.mockRejectedValue(failure('method_not_found')) + mocks.tabs.set('current', { 'wt-sender': [{ contentType: 'agent-session', entityId: ROOT }] }) + await open(chat) + expect(mocks.activateChat).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'wt-sender', sessionId: ROOT } + }) + mocks.focusRenderer.mockReturnValue(true) + await open(terminal) + expect(mocks.focusRenderer).toHaveBeenCalledWith('term_a', 'env-host') + // Only here is the chat's host known to be the older side. + await open(dispatch) + expect(mocks.toastError).toHaveBeenCalledWith(OLDER_HOST) + }) + + it('says it could not reach a sender the host cannot place, never that it is gone', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ location: null }) + await open(dispatch) + expect(mocks.toastError).toHaveBeenCalledWith(UNREACHABLE) + expect(mocks.paneUnavailable).not.toHaveBeenCalled() + expect(mocks.gone).not.toHaveBeenCalled() + }) + + it('reports an open that throws instead of leaving it unhandled', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + location: { kind: 'chat', sessionId: 'live-session', worktreeId: 'wt-sender' } + }) + mocks.activateChat.mockRejectedValueOnce(new Error('store unavailable')) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect(open(chat)).resolves.toBeUndefined() + expect(mocks.toastError).toHaveBeenCalledWith(UNREACHABLE) + expect(warned).toHaveBeenCalled() + warned.mockRestore() + }) + + it('runs one open per sender at a time', async () => { + let answer: (value: unknown) => void = () => {} + mocks.callRuntimeRpc.mockReturnValue(new Promise((resolve) => (answer = resolve))) + const first = open(chat) + const second = open(chat) + answer({ location: null, lost: 'chat' }) + await Promise.all([first, second]) + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/open-agent-message-sender.ts b/src/renderer/src/lib/open-agent-message-sender.ts new file mode 100644 index 000000000000..72112ffca0ae --- /dev/null +++ b/src/renderer/src/lib/open-agent-message-sender.ts @@ -0,0 +1,183 @@ +// Opening the agent a chat message is from: its chat through the open-chat flow, or its terminal +// through the terminal-handle link's path. Feedback is chosen by what the host found, never by how +// the sender was addressed, and uses words that already exist. + +import { toast } from 'sonner' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { agentSessionWriteNoticeText } from '@/components/native-chat/agent-session-write-notice-text' +import type { + OrchestrationPartyLocation, + OrchestrationPartyLocationResult +} from '../../../shared/orchestration-caller-status' +import { useAppStore } from '@/store' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + RuntimeRpcCallError, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' +import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' +import { + focusRendererTerminalHandle, + focusRuntimeTerminalHandle +} from '@/components/terminal-pane/terminal-handle-links' +import { showAgentPaneUnavailable } from '@/components/terminal-pane/stale-agent-row' +import { + activateAiVaultStructuredSession, + structuredSessionOpenFeedback +} from './activate-ai-vault-structured-session' +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' + +type Sender = AgentMessageSource['senders'][number] +/** `older-host`: the chat's host predates the lookup. `update`: one side is too old, either one. + * `unreachable`: no answer at all. */ +type LookupFailure = 'older-host' | 'update' | 'unreachable' +type Lookup = OrchestrationPartyLocationResult | LookupFailure + +/** The answers that prove the terminal is gone; anything else proves nothing about it. */ +const TERMINAL_GONE_CODES = new Set([ + 'terminal_exited', + 'terminal_gone', + 'terminal_handle_stale', + 'terminal_not_found' +]) + +const opensInFlight = new Map>() + +/** `chatWorktreeId`: the chat showing the message. The sender lives on that chat's host, so every + * call goes to that host. */ +export function openAgentMessageSender( + source: AgentMessageSource, + sender: Sender, + chatWorktreeId: string +): Promise { + const key = `${chatWorktreeId}\0${sender.party.address}` + const inFlight = opensInFlight.get(key) + if (inFlight) { + return inFlight + } + const opening = openSender(source, sender, chatWorktreeId) + .catch((error: unknown) => { + // Every answer above shows its own words; one that threw showed none. + console.warn('[agent-message-sender] opening the sender failed', error) + showLookupFailure('unreachable') + }) + .finally(() => opensInFlight.delete(key)) + opensInFlight.set(key, opening) + return opening +} + +async function openSender( + source: AgentMessageSource, + { party }: Sender, + chatWorktreeId: string +): Promise { + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), chatWorktreeId) + // The mail it carried from this sender, whose pane outlives a handle from an earlier run. + const messageIds = (source.orchestration?.messages ?? []) + .filter((message) => message.from === party.address) + .map((message) => message.messageId) + const found = await lookUpSender( + party, + messageIds, + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) + ) + if (typeof found === 'string') { + showLookupFailure(found) + return + } + const { location } = found + if (!location) { + if (found.lost === 'chat') { + structuredSessionOpenFeedback.gone() + } else if (found.lost === 'terminal') { + showAgentPaneUnavailable() + } else { + // Not found, and not proven gone: one this host does not run. + showLookupFailure('unreachable') + } + return + } + if (location.kind === 'chat') { + await activateAiVaultStructuredSession({ + structuredSession: { workspaceId: location.worktreeId, sessionId: location.sessionId } + }) + return + } + if (location.kind !== 'terminal') { + // A kind a newer host answers with. + showLookupFailure('update') + return + } + await focusTerminal(location.handle, environmentId) +} + +async function focusTerminal(handle: string, environmentId: string | null): Promise { + if (focusRendererTerminalHandle(handle, environmentId)) { + return + } + try { + await focusRuntimeTerminalHandle(handle, environmentId) + } catch (error) { + if (error instanceof RuntimeRpcCallError && TERMINAL_GONE_CODES.has(error.code)) { + showAgentPaneUnavailable() + } else { + showLookupFailure(isRuntimeCompatBlockError(error) ? 'update' : 'unreachable') + } + } +} + +async function lookUpSender( + party: Sender['party'], + messageIds: readonly string[], + host: RuntimeClientTarget +): Promise { + try { + return await callRuntimeRpc( + host, + 'orchestration.partyLocation', + { address: party.address, ...(messageIds.length > 0 ? { messageIds } : {}) } + ) + } catch (error) { + if (error instanceof RuntimeRpcCallError && error.code === 'method_not_found') { + return lookUpOnOlderHost(party) + } + return isRuntimeCompatBlockError(error) ? 'update' : 'unreachable' + } +} + +/** A host before the lookup: a chat open here under its root id, as one never `/clear`ed is, or a + * terminal by its handle; anything else needs that host updated. */ +function lookUpOnOlderHost(party: Sender['party']): Lookup { + const sessionId = party.orcaSessionId + if (sessionId) { + const tabsByWorktree = useAppStore.getState().unifiedTabsByWorktree + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + if (tabs.some((tab) => tab.contentType === 'agent-session' && tab.entityId === sessionId)) { + return located({ kind: 'chat', sessionId, worktreeId }) + } + } + return 'older-host' + } + const handle = party.terminalHandle + return handle && !handle.startsWith('dispatch:') && !handle.startsWith('run:') + ? located({ kind: 'terminal', handle }) + : 'older-host' +} + +function located(location: OrchestrationPartyLocation): OrchestrationPartyLocationResult { + return { location } +} + +/** Words for a sender of any kind, each sentence translated whole as write notices are. */ +function showLookupFailure(failure: LookupFailure): void { + toast.error( + agentSessionWriteNoticeText( + failure === 'older-host' + ? ['unsupported'] + : failure === 'update' + ? ['updateOrcaToOpenChat'] + : ['unreachable', 'tryAgain'] + ) + ) +} diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index a827d65296a2..3ce38f6057ce 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -17,6 +17,7 @@ import type { NativeChatToolMetadata } from './native-chat-tool-identity' import type { AgentSessionContextUsage } from './agent-session-context-usage' import type { AgentSessionProviderHandle } from './agent-session-provider-handle' import type { NativeChatBlock, NativeChatRole } from './native-chat-types' +import type { AgentMessageSource } from './agent-session-message-source' export { type AgentType } @@ -115,6 +116,9 @@ 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 } + /** Present on a message another agent sent through Orca; absent, the person's. Host-written, + * outside every fingerprint, never sent to the provider. */ + from?: AgentMessageSource /** 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 diff --git a/src/shared/agent-session-message-source.test.ts b/src/shared/agent-session-message-source.test.ts new file mode 100644 index 000000000000..c4d3cd127908 --- /dev/null +++ b/src/shared/agent-session-message-source.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { + agentMessageSendersShown, + readAgentMessageSource, + type AgentMessageSource +} from './agent-session-message-source' +import { testOrcaSessionId } from './orca-session-address-test-fixture' + +const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' + +const FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Build tab' + }, + { + party: { + address: `orca_session_id:${SESSION}`, + terminalHandle: null, + orcaSessionId: testOrcaSessionId(SESSION) + }, + name: null + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: null, + messages: [{ messageId: 'm1', runId: 'r1', from: 'term_a' }] + } +} + +describe("a message's sender, read through its one reader", () => { + it('reads absence as the person, and a valid sender back whole', () => { + expect(readAgentMessageSource(undefined)).toBeUndefined() + expect(readAgentMessageSource(structuredClone(FROM))).toEqual(FROM) + }) + + it('keeps an agent an agent when its orchestration kind is newer than this build', () => { + const newer = { ...FROM, orchestration: { message: 'task', taskId: 't1' } } + expect(readAgentMessageSource(newer)).toEqual({ ...FROM, orchestration: null }) + // A newer sender kind is still not the person. + expect(readAgentMessageSource({ ...FROM, kind: 'automation' })).toMatchObject({ + kind: 'agent' + }) + }) + + it('drops a sender it cannot read and bounds a name, keeping the rest', () => { + const read = readAgentMessageSource({ + ...FROM, + senders: [ + { party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: 'term_a' } }, + { party: { address: 'term_b', terminalHandle: 'term_b', orcaSessionId: null }, name: 7 }, + { + party: { address: 'term_c', terminalHandle: 'term_c', orcaSessionId: null }, + name: `a‮b\n${'x'.repeat(300)}` + } + ] + }) + expect(read?.senders.map(({ party, name }) => [party.address, name?.length ?? null])).toEqual([ + ['term_b', null], + ['term_c', 200] + ]) + expect(read?.senders[1]?.name?.startsWith('a b ')).toBe(true) + }) + + it("reads a value that is not a sender at all as the person's message", () => { + for (const value of [null, 'nobody', 42, [], { senders: [] }]) { + expect(readAgentMessageSource(value)).toBeUndefined() + } + }) + + it('names at most a few senders and counts the rest', () => { + const many: AgentMessageSource = { + ...FROM, + senders: Array.from({ length: 5 }, (_, index) => ({ + party: { address: `term_${index}`, terminalHandle: `term_${index}`, orcaSessionId: null }, + name: `Agent ${index}` + })) + } + const { shown, more } = agentMessageSendersShown(many) + expect(shown.map((sender) => sender.name)).toEqual(['Agent 0', 'Agent 1', 'Agent 2']) + expect(more).toBe(2) + }) +}) diff --git a/src/shared/agent-session-message-source.ts b/src/shared/agent-session-message-source.ts index f0ab9a71f139..d86d244df0a6 100644 --- a/src/shared/agent-session-message-source.ts +++ b/src/shared/agent-session-message-source.ts @@ -1,16 +1,22 @@ -// Who a chat message is from: the person at the composer, or another agent through Orca. -// Persisted with a queued card (`queued_messages.source_json`), so the chat can name each sender. +// Who a chat message is from when it is not the person at the composer: another agent, through +// Orca. Carried on the message body (`AgentJournalMessageItem.from`), outside every fingerprint. import { z } from 'zod' +import { normalizeAgentSessionConversationName } from './agent-session-conversation-name' import { isOrcaSessionId, type OrcaSessionId } from './orca-session-address' import type { OrchestrationPartyIdentity } from './orchestration-party-identity' +import { openEnum, salvagedField, salvagedOptional, salvagingArray } from './zod-salvage' /** * An agent a message is from, named by the orchestration database of the host that stores the - * message: the only host whose agents can send today. A relayed sender adds its host here. No pane - * key: it reads and consumes that agent's mailbox, so the host resolves it from the handle. + * message: a federated sender arrives as its `dispatch:` address there. No pane key: it reads + * and consumes that agent's mailbox, so the host resolves it from the address. */ -export type AgentMessageSender = Readonly<{ party: Omit }> +export type AgentMessageSender = Readonly<{ + party: Omit + /** What Orca called the sender when it wrote, kept for when it is gone; null when it had none. */ + name: string | null +}> /** One orchestration message a notice points at: its record, and its sender's `senders` address. */ export type OrchestrationMailMessage = Readonly<{ messageId: string; runId: string; from: string }> @@ -31,14 +37,23 @@ export type AgentMessageSource = Readonly<{ kind: 'agent' /** Every distinct sender of the messages it carries, in mail order. */ senders: readonly AgentMessageSender[] - orchestration: OrchestrationAgentMessage + /** Null when this build cannot read the kind a newer one wrote; the senders still stand. */ + orchestration: OrchestrationAgentMessage | null }> -export type AgentSessionMessageSource = Readonly<{ kind: 'user' }> | AgentMessageSource +/** Who a send is from, as its submission records it: the kind only (`AgentJournalSubmission.source`), + * so a restart or a close keeps only a person's unsent send as a card. The senders stay on the + * message body. */ +export type AgentSessionMessageSource = Readonly<{ kind: 'user' | 'agent' }> export const USER_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'user' } -const MESSAGE_SOURCE_VERSION = 1 +export const AGENT_MESSAGE_SOURCE: AgentSessionMessageSource = { kind: 'agent' } + +/** A sender name as stored: bounded and flattened like a conversation name, never raw. */ +export function agentMessageSenderName(value: unknown): string | null { + return normalizeAgentSessionConversationName(value) +} const orcaSessionIdSchema = z.custom( (value) => typeof value === 'string' && isOrcaSessionId(value) @@ -51,38 +66,43 @@ const mailNoticeSchema = z.object({ messages: z.array(z.object({ messageId: z.string(), runId: z.string(), from: z.string() })) }) -// Not strict: a newer build may add a field, which this one keeps no use for and must not reject. -const storedSourceSchema = z.discriminatedUnion('kind', [ - z.object({ v: z.literal(MESSAGE_SOURCE_VERSION), kind: z.literal('user') }), - z.object({ - v: z.literal(MESSAGE_SOURCE_VERSION), - kind: z.literal('agent'), - senders: z.array( - z.object({ - party: z.object({ - address: z.string(), - terminalHandle: z.string().nullable(), - orcaSessionId: orcaSessionIdSchema.nullable() - }) - }) - ), - orchestration: z.discriminatedUnion('message', [mailNoticeSchema]) - }) -]) - -export function serializeAgentSessionMessageSource(source: AgentSessionMessageSource): string { - return JSON.stringify({ v: MESSAGE_SOURCE_VERSION, ...source }) +const senderSchema = z.object({ + party: z.object({ + address: z.string().min(1), + terminalHandle: z.string().nullable(), + orcaSessionId: orcaSessionIdSchema.nullable() + }), + name: z.unknown().transform(agentMessageSenderName) +}) + +// Read apart, so a malformed sender or an orchestration kind this build lacks never turns an +// agent's message into the person's. +const sourceSchema = z.object({ + kind: openEnum(['agent'], 'agent'), + senders: salvagedField('senders', salvagingArray(senderSchema), () => []), + orchestration: salvagedOptional( + 'orchestration', + z.discriminatedUnion('message', [mailNoticeSchema]) + ).transform((orchestration) => orchestration ?? null) +}) + +/** The one reader of a stored or published `from`: undefined (the person's) when it is absent + * or not an agent's at all. */ +export function readAgentMessageSource(value: unknown): AgentMessageSource | undefined { + if (value === undefined) { + return undefined + } + const parsed = sourceSchema.safeParse(value) + return parsed.success ? parsed.data : undefined } -/** - * The stored value read back. Absent (a card from before the column) is the person's: only the - * composer queued then. So is a value this build cannot read; either way it is sent as written. - */ -export function readAgentSessionMessageSource(stored: unknown): AgentSessionMessageSource { - const parsed = storedSourceSchema.safeParse(stored) - if (!parsed.success) { - return USER_MESSAGE_SOURCE +/** The senders a label names, and how many more it counts; one notice can carry many. */ +export function agentMessageSendersShown( + source: AgentMessageSource, + limit = 3 +): { shown: readonly AgentMessageSender[]; more: number } { + return { + shown: source.senders.slice(0, limit), + more: Math.max(0, source.senders.length - limit) } - const { v: _version, ...source } = parsed.data - return source } diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 6b271cb122bd..a85f991aad83 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -22,6 +22,7 @@ import type { } from './agent-session-journal-types' import type { AgentType } from './agent-status-types' import type { NativeChatToolMetadata } from './native-chat-tool-identity' +import type { AgentMessageSource } from './agent-session-message-source' export type { AgentType } @@ -233,6 +234,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { completedAt?: number /** On a conversation command the user sent, such as `/compact`: the command it names. */ command?: { name: string } + /** On a user-role message another agent sent through Orca: who, as the journal recorded it. */ + from?: AgentMessageSource /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ queued?: true /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where diff --git a/src/shared/orchestration-caller-status.ts b/src/shared/orchestration-caller-status.ts index 3f701739b19b..4bd1e3de62bb 100644 --- a/src/shared/orchestration-caller-status.ts +++ b/src/shared/orchestration-caller-status.ts @@ -25,3 +25,15 @@ export type OrchestrationCallerShowResult = { caller: OrchestrationCallerSession /** `orchestration.sessionAddress`: a session's Orca session ID, its `/clear` root's. */ export type OrchestrationSessionAddressResult = { orcaSessionId: string } + +/** Where a party is now: a chat at its `/clear` lineage's live session, or a terminal. */ +export type OrchestrationPartyLocation = + | { kind: 'chat'; sessionId: string; worktreeId: string } + | { kind: 'terminal'; handle: string } + +/** `orchestration.partyLocation`: a null location is a party this host does not find. `lost` says + * it proved that party gone, and what it was; absent, the host cannot tell (one it does not run). */ +export type OrchestrationPartyLocationResult = { + location: OrchestrationPartyLocation | null + lost?: 'chat' | 'terminal' +} diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts index 47d3edd1c04c..cfbecf7bf7a7 100644 --- a/src/shared/rpc-contract/orchestration-params.ts +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -111,6 +111,13 @@ export const SessionAddressParams = z.object({ sessionId: requiredString('Missing sessionId') }) +/** A party's mailbox address, as a message's sender recorded it, and the mail that message + * carried from it: a handle from an earlier run is found again through that mail's sender pane. */ +export const PartyLocationParams = z.object({ + address: requiredString('Missing address'), + messageIds: z.array(z.string().min(1).max(256)).max(50).optional() +}) + export const DispatchShowParams = z.object({ task: OptionalString, preamble: OptionalBoolean, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7276915e1d93..0e1c72a2f64d 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -384,6 +384,7 @@ import { DispatchParams, DispatchShowParams, InboxParams, + PartyLocationParams, ReplyParams, ResetParams, SessionAddressParams, @@ -1023,6 +1024,7 @@ export const RPC_PARAMS_BY_METHOD = { 'orchestration.gateList': GateListParams, 'orchestration.gateResolve': GateResolveParams, 'orchestration.inbox': InboxParams, + 'orchestration.partyLocation': PartyLocationParams, 'orchestration.reply': ReplyParams, 'orchestration.requestShow': RequestShowParams, 'orchestration.reset': ResetParams, diff --git a/src/shared/structured-agent-session-projection-sender.test.ts b/src/shared/structured-agent-session-projection-sender.test.ts new file mode 100644 index 000000000000..18a41c68dbd7 --- /dev/null +++ b/src/shared/structured-agent-session-projection-sender.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem, AgentJournalRenderItem } from './agent-session-journal-types' +import type { AgentMessageSource } from './agent-session-message-source' +import { projectStructuredItemToNativeChat } from './structured-agent-session-projection' + +const FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_a', terminalHandle: 'term_a', orcaSessionId: null }, + name: 'Coder' + } + ], + orchestration: null +} + +function message(role: 'user' | 'assistant', from?: unknown): AgentJournalRenderItem { + const body: AgentJournalMessageItem = { + kind: 'message', + role, + blocks: [{ type: 'text', text: 'hi' }] + } + return { + itemId: `orca:submission:${role}`, + sequence: 1, + revision: 0, + observedAt: 1, + // A body as a client receives it: off the wire, from a host of any version. + body: from === undefined ? body : Object.assign(body, { from: structuredClone(from) }) + } +} + +describe("a message's sender, projected for every client", () => { + it('carries the sender of a user-role message another agent sent', () => { + expect(projectStructuredItemToNativeChat(message('user', FROM))?.from).toEqual(FROM) + }) + + it("leaves the person's message, and a value no reader can place, without one", () => { + expect(projectStructuredItemToNativeChat(message('user'))).not.toHaveProperty('from') + expect(projectStructuredItemToNativeChat(message('user', 'nobody'))).not.toHaveProperty('from') + expect(projectStructuredItemToNativeChat(message('assistant', FROM))).not.toHaveProperty('from') + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 6eee55bbeda9..1e7f6c0d5b25 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -36,6 +36,7 @@ import { import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' import { sha256 } from './sha256' +import { readAgentMessageSource } from './agent-session-message-source' import { structuredAgentSessionStatusStartedAt } from './structured-agent-session-status-started-at' import { owesStructuredAgentSessionWork } from './structured-agent-session-owed-work' @@ -183,6 +184,8 @@ export function projectStructuredItemToNativeChat( 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 + // Read here too: a client's journal comes off the wire, from a host of any version. + const from = item.body.kind === 'message' ? readAgentMessageSource(item.body.from) : undefined const message: NativeChatMessage | null = projected ? { ...agentJournalItemRowOrigin(item), @@ -192,7 +195,8 @@ export function projectStructuredItemToNativeChat( // A send mode this build cannot name renders as an ordinary message. ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}), ...lifecycle, - ...(command ? { command } : {}) + ...(command ? { command } : {}), + ...(from && projected.role === 'user' ? { from } : {}) } : null projectedItems.set(item, message) diff --git a/src/shared/structured-agent-session-send-mutation.test.ts b/src/shared/structured-agent-session-send-mutation.test.ts new file mode 100644 index 000000000000..353186e4da11 --- /dev/null +++ b/src/shared/structured-agent-session-send-mutation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentMessageSource } from './agent-session-message-source' +import { SendParams } from './rpc-contract/structured-agent-session-params' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { + agentSessionSendBodyFingerprint, + structuredAgentSessionMessageSendMutation +} from './structured-agent-session-send-mutation' + +const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'You have 1 orchestration message.' }] +} + +const FROM: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'term_peer', terminalHandle: 'term_peer', orcaSessionId: null }, + name: 'Claude' + } + ], + orchestration: { message: 'mail-notice', mailbox: 'run:r1', dispatchId: null, messages: [] } +} + +describe('send fingerprints never cover the sender', () => { + it('keeps the digest every stored submission and draft already holds', () => { + // Pinned bytes: a stored fingerprint that changed would stop matching its provider echo. + expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( + 'bf73e28368c0837ade16328692116535c6d97fc9924c70aa342a3cf18817d047' + ) + expect(agentSessionSendBodyFingerprint('session-1', BODY)).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: BODY } + }) + ) + }) + + it('fingerprints a body with a sender exactly as the same body without one', () => { + expect(agentSessionSendBodyFingerprint('session-1', { ...BODY, from: FROM })).toBe( + agentSessionSendBodyFingerprint('session-1', BODY) + ) + const send = (body: AgentJournalMessageItem) => + structuredAgentSessionMessageSendMutation({ + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + body, + delivery: 'queue-if-active' + }) + const withSender = send({ ...BODY, from: FROM }) + expect(withSender.envelope.payloadFingerprint).toBe(send(BODY).envelope.payloadFingerprint) + // The sender still travels with the message itself. + expect(withSender.body.from).toEqual(FROM) + }) + + it('refuses a client send whose body names a sender', () => { + const params = (body: unknown) => ({ + envelope: { + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + payloadFingerprint: 'a'.repeat(64) + }, + body + }) + expect(SendParams.safeParse(params(BODY)).success).toBe(true) + expect(SendParams.safeParse(params({ ...BODY, from: FROM })).success).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-send-mutation.ts b/src/shared/structured-agent-session-send-mutation.ts index 420611ac0b93..ddefedb5ab54 100644 --- a/src/shared/structured-agent-session-send-mutation.ts +++ b/src/shared/structured-agent-session-send-mutation.ts @@ -1,4 +1,4 @@ -import type { AgentJournalMessageItem } from './agent-session-journal-types' +import type { AgentJournalItemBody, AgentJournalMessageItem } from './agent-session-journal-types' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' @@ -8,6 +8,25 @@ export type StructuredAgentSessionSendMutation = { delivery?: 'queue-if-active' } +/** What every send fingerprint covers: the message without its sender, which never reaches the + * provider, so its echo still matches. A body with no sender hashes exactly as it always has. */ +export function agentSessionMessagePayload(body: AgentJournalMessageItem): AgentJournalMessageItem { + const { from: _sender, ...payload } = body + return payload +} + +/** The body-only hash a submission and a queued draft store, and a provider echo is matched by. */ +export function agentSessionSendBodyFingerprint( + sessionId: string, + body: AgentJournalItemBody +): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body: body.kind === 'message' ? agentSessionMessagePayload(body) : body } + }) +} + /** The `agentSession.send` arguments for one message. Typed rather than wire-shaped so a host * calling its own send path builds the same envelope a client would, fingerprint included. */ export function structuredAgentSessionMessageSendMutation(message: { @@ -18,7 +37,7 @@ export function structuredAgentSessionMessageSendMutation(message: { delivery?: 'queue-if-active' }): StructuredAgentSessionSendMutation { // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. - const fields = { body: message.body, ...(message.delivery ? { delivery: message.delivery } : {}) } + const delivery = message.delivery ? { delivery: message.delivery } : {} return { envelope: { sessionId: message.sessionId, @@ -27,9 +46,10 @@ export function structuredAgentSessionMessageSendMutation(message: { payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: 'agentSession.send', sessionId: message.sessionId, - fields + fields: { body: agentSessionMessagePayload(message.body), ...delivery } }) }, - ...fields + body: message.body, + ...delivery } } 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 index 86eed2b8271e..24b495575fbd 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -139,8 +139,7 @@ test('an older build lists a kept card first and never sends it, even after a pe messageId: 'queued-card', body: message('queued-card'), fingerprint: 'fp-queued-card', - hostInstance: 'host-a', - source: USER_MESSAGE_SOURCE + hostInstance: 'host-a' }) await acceptPersonSend(earlier, 'kept') await journals.closeAll() From 72b84118e07358ee31caad6f8c73b530022079fa Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:26:49 -0400 Subject: [PATCH 002/394] test(e2e): terminal layout parity check against main (#25681) * test(e2e): add terminal layout parity check for topology refactor PRs Runs fixed terminal-layout journeys in the real app on two builds, captures the renderer topology and the saved workspace session, normalizes volatile values and fails on any difference not declared for a named bug. Co-Authored-By: Claude * test(e2e): record quit exit status and report paths main does not reproduce Co-Authored-By: Claude * test(e2e): invoke pnpm correctly under corepack and silence the typeless-module warning Co-Authored-By: Claude * test(e2e): accept pnpm's forwarded -- in the layout parity runner Co-Authored-By: Claude * test(e2e): close parity panes through the user's chord and treat absent maps as empty Driving PaneManager.closePane directly left main to learn of the close from the PTY exit, which raced quit; the keyboard path commits the close in main first. Co-Authored-By: Claude * test(e2e): build parity checkouts before running, forward -g, and add a drag-out scenario Co-Authored-By: Claude --------- Co-authored-by: Claude --- config/scripts/run-terminal-layout-parity.mjs | 229 +++++++++ package.json | 1 + tests/e2e/AGENTS.md | 10 + tests/e2e/helpers/runtime-types.ts | 1 + ...inal-layout-parity-declared-differences.ts | 13 + tests/e2e/terminal-layout-parity-snapshot.ts | 266 +++++++++++ ...rminal-layout-parity-snapshot.unit.test.ts | 131 ++++++ tests/e2e/terminal-layout-parity.spec.ts | 440 ++++++++++++++++++ 8 files changed, 1091 insertions(+) create mode 100644 config/scripts/run-terminal-layout-parity.mjs create mode 100644 tests/e2e/terminal-layout-parity-declared-differences.ts create mode 100644 tests/e2e/terminal-layout-parity-snapshot.ts create mode 100644 tests/e2e/terminal-layout-parity-snapshot.unit.test.ts create mode 100644 tests/e2e/terminal-layout-parity.spec.ts diff --git a/config/scripts/run-terminal-layout-parity.mjs b/config/scripts/run-terminal-layout-parity.mjs new file mode 100644 index 000000000000..c1d5033d75df --- /dev/null +++ b/config/scripts/run-terminal-layout-parity.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// Runs tests/e2e/terminal-layout-parity.spec.ts against a base and a head build and diffs the +// captured layouts and saved sessions. Usage: tests/e2e/AGENTS.md. + +import { build } from 'esbuild' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseArgs } from 'node:util' +import { describeProcessFailure, runProcessSync } from './script-child-process.mjs' +const repoRoot = resolve(import.meta.dirname, '../..') +const BUILD_STAMP = join('out', '.terminal-layout-parity-build') + +// Bundled like script-child-process.mjs: importing the .ts directly makes Node warn MODULE_TYPELESS_PACKAGE_JSON. +async function importParityModules() { + const temporary = mkdtempSync(join(tmpdir(), 'orca-layout-parity-')) + try { + const outfile = join(temporary, 'parity.mjs') + await build({ + stdin: { + contents: ['snapshot', 'declared-differences'] + .map( + (name) => + `export * from ${JSON.stringify(join(repoRoot, 'tests', 'e2e', `terminal-layout-parity-${name}.ts`))}` + ) + .join('\n'), + resolveDir: repoRoot, + sourcefile: 'terminal-layout-parity-entry.ts' + }, + bundle: true, + platform: 'node', + format: 'esm', + outfile, + logLevel: 'silent' + }) + return await import(pathToFileURL(outfile).href) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } +} + +const { + TERMINAL_LAYOUT_PARITY_OUT_ENV, + TERMINAL_LAYOUT_PARITY_DECLARED_DIFFERENCES, + TERMINAL_LAYOUT_PARITY_UNSTABLE_ON_MAIN, + compareParityCaptures, + isParityClean +} = await importParityModules() + +// `pnpm run ', + 'not-a-token': 'blue', + '--Upper': 'green' + } + }) + expect(css).not.toContain('') + expect(css).not.toContain('}<') + expect(css).not.toContain('not-a-token') + expect(css).not.toContain('--Upper') + expect(css.startsWith(':root{color-scheme:light')).toBe(true) + }) + + it('builds the live theme message on the same channel', () => { + expect(nativeChatVisualThemeMessage(theme, 'abc')).toMatchObject({ + type: NATIVE_CHAT_VISUAL_THEME_TYPE, + channel: 'abc', + colorScheme: 'dark' + }) + }) +}) + +describe('readNativeChatVisualFrameMessage', () => { + it('accepts a finite size and an http(s) link on its own channel', () => { + expect( + readNativeChatVisualFrameMessage( + { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: 321.5 }, + 'c' + ) + ).toEqual({ kind: 'size', height: 321.5 }) + expect( + readNativeChatVisualFrameMessage( + { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'https://example.com/a b' }, + 'c' + ) + ).toEqual({ kind: 'open-link', url: 'https://example.com/a%20b' }) + }) + + it.each([ + ['another channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'x', height: 10 }], + ['no channel', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, height: 10 }], + ['NaN height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Number.NaN }], + ['infinite height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: Infinity }], + ['negative height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: -5 }], + ['string height', { type: NATIVE_CHAT_VISUAL_SIZE_TYPE, channel: 'c', height: '50' }], + [ + 'javascript link', + { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'javascript:alert(1)' } + ], + ['file link', { type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, channel: 'c', url: 'file:///etc' }], + [ + 'overlong link', + { + type: NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE, + channel: 'c', + url: `https://example.com/${'a'.repeat(3000)}` + } + ], + ['unknown type', { type: 'orca-panel-action', channel: 'c' }], + ['a string', 'orca-visual-size'], + ['null', null] + ])('refuses %s', (_name, data) => { + expect(readNativeChatVisualFrameMessage(data, 'c')).toBeNull() + }) +}) + +describe('clampNativeChatVisualHeight', () => { + it('keeps heights in range', () => { + expect(clampNativeChatVisualHeight(1)).toBe(NATIVE_CHAT_VISUAL_MIN_HEIGHT) + expect(clampNativeChatVisualHeight(99_999)).toBe(NATIVE_CHAT_VISUAL_MAX_HEIGHT) + expect(clampNativeChatVisualHeight(300.4)).toBe(300) + }) +}) diff --git a/src/shared/native-chat-visual-shell.ts b/src/shared/native-chat-visual-shell.ts new file mode 100644 index 000000000000..6df2531d6eef --- /dev/null +++ b/src/shared/native-chat-visual-shell.ts @@ -0,0 +1,261 @@ +import { PANEL_DESIGN_TOKEN_ALLOWLIST } from './plugins/plugin-panel-shell' + +/** + * The document wrapped around an agent-written chat visual before it enters its sandboxed frame. + * Same placement rule as the plugin panel shell: the CSP meta parses before any visual markup, so + * nothing later in the document can loosen it, and the visual's own / merge into ours. + * + * Network: scripts, styles, fonts and images may load from a pinned set of public CDNs; nothing may + * fetch, open sockets, frame, or submit. A page can still encode data in a request to an allowed + * CDN — an accepted, disclosed risk, not a guarantee that no data leaves. + * + * Electron-free string builder: desktop, the web client and mobile build the same document. + */ + +export const NATIVE_CHAT_VISUAL_CDN_ORIGINS = [ + 'https://cdn.jsdelivr.net', + 'https://unpkg.com', + 'https://cdnjs.cloudflare.com', + 'https://esm.sh', + 'https://fonts.googleapis.com', + 'https://fonts.gstatic.com' +] as const + +const ASSET_SOURCES = `'unsafe-inline' data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}` +const MEDIA_SOURCES = `data: blob: ${NATIVE_CHAT_VISUAL_CDN_ORIGINS.join(' ')}` + +export const NATIVE_CHAT_VISUAL_CSP = [ + "default-src 'none'", + `script-src ${ASSET_SOURCES}`, + `style-src ${ASSET_SOURCES}`, + `font-src ${MEDIA_SOURCES}`, + `img-src ${MEDIA_SOURCES}`, + "connect-src 'none'", + "frame-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "object-src 'none'", + "media-src 'none'", + "manifest-src 'none'", + "form-action 'none'", + "base-uri 'none'" +].join('; ') + +/** Frame names carry this prefix so the main process registers them before content runs. */ +export const NATIVE_CHAT_VISUAL_FRAME_NAME_PREFIX = 'orca-chat-visual:' + +export const NATIVE_CHAT_VISUAL_SIZE_TYPE = 'orca-visual-size' +export const NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE = 'orca-visual-open-link' +export const NATIVE_CHAT_VISUAL_THEME_TYPE = 'orca-visual-theme' + +export const NATIVE_CHAT_VISUAL_MIN_HEIGHT = 80 +export const NATIVE_CHAT_VISUAL_MAX_HEIGHT = 2000 +const MAX_LINK_LENGTH = 2048 + +/** Theme variables a visual may style against: the plugin panel set plus the chart series. */ +export const NATIVE_CHAT_VISUAL_THEME_TOKENS = [ + ...PANEL_DESIGN_TOKEN_ALLOWLIST, + '--chart-1', + '--chart-2', + '--chart-3', + '--chart-4', + '--chart-5', + '--font-sans', + '--font-mono' +] as const + +export type NativeChatVisualTheme = { + colorScheme: 'light' | 'dark' + tokens: Readonly> +} + +const TOKEN_NAME_PATTERN = /^--[a-z0-9-]+$/ + +/** Token values land inside a + + + +${args.html}` +} + +export type NativeChatVisualFrameMessage = + | { kind: 'size'; height: number } + | { kind: 'open-link'; url: string } + +/** A message from a visual's shell, validated; null for anything else. */ +export function readNativeChatVisualFrameMessage( + data: unknown, + channel: string +): NativeChatVisualFrameMessage | null { + if (typeof data !== 'object' || data === null) { + return null + } + if (!('channel' in data) || data.channel !== channel || !('type' in data)) { + return null + } + if (data.type === NATIVE_CHAT_VISUAL_SIZE_TYPE) { + const height = 'height' in data ? data.height : undefined + return typeof height === 'number' && Number.isFinite(height) && height > 0 + ? { kind: 'size', height } + : null + } + if (data.type === NATIVE_CHAT_VISUAL_OPEN_LINK_TYPE) { + const url = 'url' in data ? data.url : undefined + if (typeof url !== 'string' || url.length > MAX_LINK_LENGTH) { + return null + } + try { + const parsed = new URL(url) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? { kind: 'open-link', url: parsed.href } + : null + } catch { + return null + } + } + return null +} + +export function clampNativeChatVisualHeight(height: number): number { + return Math.min( + NATIVE_CHAT_VISUAL_MAX_HEIGHT, + Math.max(NATIVE_CHAT_VISUAL_MIN_HEIGHT, Math.round(height)) + ) +} + +export function nativeChatVisualThemeMessage( + theme: NativeChatVisualTheme, + channel: string +): { type: string; channel: string; css: string; colorScheme: 'light' | 'dark' } { + return { + type: NATIVE_CHAT_VISUAL_THEME_TYPE, + channel, + css: nativeChatVisualThemeCss(theme), + colorScheme: theme.colorScheme + } +} diff --git a/src/shared/rpc-contract/agent-session-visual-params.ts b/src/shared/rpc-contract/agent-session-visual-params.ts new file mode 100644 index 000000000000..bc2e67562615 --- /dev/null +++ b/src/shared/rpc-contract/agent-session-visual-params.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' +import { isNativeChatVisualFileName } from '../native-chat-visual-directive' +import { SessionId } from './structured-agent-session-identifiers' + +// `agentSession.readVisual`: one HTML visual from a structured chat's visuals folder, read on the +// host that owns the chat. The client names only the session and a bare file name; the host +// resolves its own folder, so no client path ever reaches another host. + +/** Content revision: hex digest of the file's bytes. */ +const Revision = z.string().regex(/^[0-9a-f]{16,64}$/) + +export const ReadVisualParams = z + .object({ + sessionId: SessionId, + file: z.string().refine(isNativeChatVisualFileName, 'Invalid visual file name'), + // The revision the client already holds; a match answers `unchanged` without the bytes. + knownRevision: Revision.optional() + }) + .strict() + +export type AgentSessionReadVisualParams = z.infer + +/** + * Why a visual cannot be shown, as positively observed by the owning host. Transport loss and an + * older host are never one of these: the client reads those as unavailable and may retry. + */ +export const AGENT_SESSION_VISUAL_READ_ERRORS = [ + 'session_not_found', + 'unsupported_location', + 'not_found', + 'not_a_file', + 'outside_folder', + 'too_large', + 'not_text' +] as const + +export type AgentSessionVisualReadError = (typeof AGENT_SESSION_VISUAL_READ_ERRORS)[number] + +export type AgentSessionReadVisualResult = + | { ok: true; revision: string; sizeBytes: number; html: string } + | { ok: true; revision: string; sizeBytes: number; unchanged: true } + | { ok: false; error: AgentSessionVisualReadError } diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 89dbbb772231..5058efda5665 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -42,6 +42,7 @@ import { import { PrepareCodexForWslPaneParams } from './agent-hooks-params' import { AgentLaunch, AgentLaunchReplay } from './agent-launch-params' import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params' +import { ReadVisualParams } from './agent-session-visual-params' import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, @@ -615,6 +616,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.queuedMessageDelete': QueuedMessageActionParams, 'agentSession.queuedMessageSend': QueuedMessageActionParams, 'agentSession.queuedMessagesResume': QueuedMessagesResumeParams, + 'agentSession.readVisual': ReadVisualParams, 'agentSession.release': HoldParams, 'agentSession.respondToApproval': RespondParams, 'agentSession.respondToQuestion': RespondToQuestionParams, diff --git a/src/shared/structured-agent-session-latest-request.test.ts b/src/shared/structured-agent-session-latest-request.test.ts index 6bb7f117bf80..6f73d8ecb820 100644 --- a/src/shared/structured-agent-session-latest-request.test.ts +++ b/src/shared/structured-agent-session-latest-request.test.ts @@ -319,3 +319,37 @@ describe('the sidebar verdict agrees with the rejection classifier', () => { } ) }) + +describe('the assistant line plain-text surfaces show', () => { + function message( + itemId: string, + sequence: number, + role: 'user' | 'assistant', + text: string + ): AgentJournalRenderItem { + return { + itemId, + revision: 0, + sequence, + observedAt: sequence, + body: { kind: 'message', role, blocks: [{ type: 'text', text }] } + } + } + + it('keeps a visual line out of the preview every status reader shows', () => { + const items = [ + message('ask', 1, 'user', 'chart it'), + message( + 'said', + 2, + 'assistant', + 'p95 is highest in ap-south.\n\n::orca-visual{file="latency.html" title="p95"}' + ), + message('only-visual', 3, 'assistant', '::orca-visual{file="table.html"}') + ] + // A reply that is nothing but a visual leaves the turn's earlier prose as the preview. + expect(projectStructuredAgentSessionStatusSummary(items).lastAssistantMessage).toBe( + 'p95 is highest in ap-south.' + ) + }) +}) diff --git a/src/shared/structured-agent-session-latest-request.ts b/src/shared/structured-agent-session-latest-request.ts index 35856a21552d..921d90d5cabb 100644 --- a/src/shared/structured-agent-session-latest-request.ts +++ b/src/shared/structured-agent-session-latest-request.ts @@ -18,6 +18,7 @@ import { agentJournalSubmissionKey } from './agent-session-journal-item-key' import { isRootAgentJournalItem } from './agent-session-journal-producer' import { readAgentJournalTurn, readAgentJournalTurnOutcome } from './agent-session-turn-record' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import { withoutNativeChatVisualDirectiveLines } from './native-chat-visual-directive' import { isUnansweredStructuredAgentSessionDispatch } from './structured-agent-session-unanswered-dispatch' import { isStructuredAgentSessionCommandEntry, @@ -187,7 +188,8 @@ export function latestStructuredAgentSessionAssistantMessage( return '' } if (body?.kind === 'message' && body.role === 'assistant') { - const prose = messageProse(body.blocks) + // A visual line shows only in the transcript; every plain-text reader of this line drops it. + const prose = withoutNativeChatVisualDirectiveLines(messageProse(body.blocks)) if (prose.trim()) { return prose } diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 8ee66ede3b51..399831b9e03e 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -140,6 +140,14 @@ export const STRUCTURED_CALLS: { hostMethod: 'revealSession', result: { ok: true, sessionId: SESSION, workspaceId: WORKSPACE, agent: 'codex', readable: true } }, + // A chat's visual, read from the host's own record and state directory. A bare addition: an older + // host answers `method_not_found` and the client shows the visual as unavailable. The stub host + // holds no record, so the typed refusal is the declared answer. + { + method: 'agentSession.readVisual', + hostMethod: null, + result: { ok: false, error: 'session_not_found' } + }, // A no-op on a host that starts an agent only for work; it still builds the host. { method: 'agentSession.hold', hostMethod: null, result: { held: true } }, // The restart-resume surface. Bare additions, not capability-negotiated: an RPC method's @@ -326,6 +334,8 @@ export function paramsFor(method: string): unknown { return { ...ATTENTION_READ, observedCursor: { ...ATTENTION_READ.observedCursor } } case 'agentSession.modelCatalog': return { agent: 'codex', sessionId: SESSION } + case 'agentSession.readVisual': + return { sessionId: SESSION, file: 'usage-chart.html' } case 'agentSession.hold': case 'agentSession.release': return { sessionId: SESSION, holderId: 'surface-1' } From 7f43d9b2c2606228ba14616cb1f566a116c08af3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:12:25 -0700 Subject: [PATCH 109/394] test(startup): advance mocked display readiness polling (#26283) --- src/main/startup/ensure-virtual-display.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index ded8360bce5c..a895ee74edf8 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -240,8 +240,20 @@ describe('ensureVirtualDisplayForHeadlessServe', () => { spawnMock.mockReturnValue({ pid: 4242, once: vi.fn(), kill: vi.fn(), killed: false }) const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display') - expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) - expect(process.env.DISPLAY).toBeUndefined() + vi.useFakeTimers({ toFake: ['Date'] }) + const sleep = vi + .spyOn(Atomics, 'wait') + .mockImplementation((_array, _index, _value, timeout) => { + vi.setSystemTime(Date.now() + (timeout ?? 0)) + return 'timed-out' + }) + try { + expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(false) + expect(process.env.DISPLAY).toBeUndefined() + } finally { + sleep.mockRestore() + vi.useRealTimers() + } }) it('accepts the display once the spawned Xvfb owns its lock', async () => { From a0026588e41ec323386ab94ce68ea46db35be171 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:45:26 -0400 Subject: [PATCH 110/394] terminal: window says where each new terminal goes (no behavior change) (#26078) * refactor(terminal): renderer senders attach placement to pty spawn, report-only The window now says where each fresh terminal goes: the first pane of a tab sends new-tab with the tab's creation fields, a split sends the parent leaf, direction, ratio and the tree after the split, background launches send the same, and a Codex restart that rebuilds a rootless layout sends root. A reattach sends nothing. Main still only records whether placement names the tab its binding write picks; its 'absent' value now counts only new leaves without placement, so it reads the fallback mint and graft directly. Extends the shared placement type with optional row, ratio and proposedRoot, each dropped alone if malformed, so older and newer peers keep working. * fix(terminal): background agent placement row claims no launchAgent the tab lacks The adopted tab is created without launchAgent, so a row naming one would diverge from the renderer's tab once main applies placement. * fix(terminal): a before-split publishes its pane with the final tree order wrapInSplit now places the new pane first itself, so the pane-created handler's proposedRoot sees the real post-split tree instead of the order before the subtree split moved it. * test(terminal): type the background-terminal tab patch precisely --- .../pty-binding-placement-inertness.test.ts | 2 +- .../terminal-pane-placement-agreement.ts | 11 +- .../codex-detached-pane-restart.test.ts | 4 +- .../codex-detached-pane-restart.ts | 30 +++--- .../terminal-pane/ipc-pty-spawn-request.ts | 2 + .../pty-connection-fresh-spawn-guards.test.ts | 12 +++ .../terminal-pane/pty-connection-types.ts | 2 + .../pty-connection/pty-input-recovery.ts | 1 + .../pty-transport-connect-spawn.test.ts | 20 ++++ .../terminal-pane/pty-transport-types.ts | 3 + .../terminal-pane-pane-created.ts | 10 ++ .../terminal-pane-spawn-placement.test.ts | 101 ++++++++++++++++++ .../terminal-pane-spawn-placement.ts | 23 ++++ .../src/lib/background-pane-exit-output.ts | 46 ++++++++ .../launch-agent-background-session.test.ts | 3 +- .../lib/launch-agent-background-session.ts | 2 + ...unch-worktree-background-terminals.test.ts | 35 +++++- .../launch-worktree-background-terminals.ts | 82 ++++++-------- .../pane-manager-pane-creation.ts | 2 +- .../pane-manager-spawn-placement.test.ts | 95 ++++++++++++++++ .../lib/pane-manager/pane-manager-types.ts | 5 +- .../pane-manager/pane-overlay-focus.test.ts | 2 +- .../lib/pane-manager/pane-split-close.test.ts | 7 +- .../src/lib/pane-manager/pane-split-close.ts | 23 ++-- .../lib/pane-manager/pane-subtree-split.ts | 32 +----- .../src/lib/pane-manager/pane-tree-ops.ts | 12 ++- .../src/lib/terminal-pane-placement-row.ts | 29 +++++ src/shared/terminal-pane-placement.test.ts | 44 ++++++++ src/shared/terminal-pane-placement.ts | 30 +++++- src/shared/workspace-session-schema.ts | 4 +- 30 files changed, 549 insertions(+), 125 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts create mode 100644 src/renderer/src/lib/background-pane-exit-output.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts create mode 100644 src/renderer/src/lib/terminal-pane-placement-row.ts diff --git a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts index 2ad1c882f23b..ce412cf4633d 100644 --- a/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts +++ b/src/main/persistence/loading-store/pty-binding-placement-inertness.test.ts @@ -232,7 +232,7 @@ describe('placement on the binding write is inert', () => { it(`${scenario.name}: memory and saved state match a write without placement`, async () => { const baseline = await bindAndSave(scenario, undefined) expect(baseline.result).toBe(true) - expect(placementAttributes).toEqual(['absent']) + expect(placementAttributes).toEqual([scenario.prebind ? 'leaf_present' : 'absent']) const cases = scenario.placements('') for (const [index, [, agreement]] of cases.entries()) { const placed = await bindAndSave(scenario, (leaf) => scenario.placements(leaf)[index][0]) diff --git a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts index 0a104592d94f..3376859499e2 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-placement-agreement.ts @@ -2,7 +2,10 @@ import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placem import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' -/** Low-cardinality: it lands on the `persistence.pty-binding` span. */ +/** + * Low-cardinality: it lands on the `persistence.pty-binding` span. `absent` counts new leaves with + * no placement, which only the mint and graft can place; it should read zero before they go. + */ export type TerminalPanePlacementAgreement = | 'absent' | 'leaf_present' @@ -24,14 +27,14 @@ export function terminalPanePlacementAgreement( tabId: string, leafId: string ): TerminalPanePlacementAgreement { - if (!placement) { - return 'absent' - } const tabExists = session.tabsByWorktree?.[worktreeId]?.some((tab) => tab.id === tabId) === true const root = session.terminalLayoutsByTabId?.[tabId]?.root ?? null if (tabExists && layoutContainsLeafId(root, leafId)) { return 'leaf_present' } + if (!placement) { + return 'absent' + } switch (placement.kind) { case 'new-tab': return tabExists ? 'tab_exists' : 'agrees' diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts index 8aea14403649..1aa4adfcd9b9 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts @@ -126,6 +126,8 @@ describe('codex detached pane restart executor', () => { initiallyHidden: true }) ) + // The leaf is in the root, so main already knows where it goes. + expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]).not.toHaveProperty('placement') expect(window.api.pty.getSize).not.toHaveBeenCalled() expect(vi.mocked(window.api.pty.spawn).mock.calls[0]?.[0]?.env).toEqual( expect.objectContaining({ @@ -310,7 +312,7 @@ describe('codex detached pane restart executor', () => { }) ) expect(window.api.pty.spawn).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ replacesPtyId: OLD_PTY }) + expect.objectContaining({ replacesPtyId: OLD_PTY, placement: { kind: 'root' } }) ) }) diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 6c3228ae10bd..027e6ee156ab 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -10,7 +10,7 @@ */ import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' @@ -25,6 +25,7 @@ import { hasCachedWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { ptyDataHandlers, unregisterPtyDataHandlers } from './pty-dispatcher' +import { collectLeafIds } from './terminal-pane-layout-tree' import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' import { disposeParkedTerminalWatchersForPtyIds } from './terminal-parked-watcher-registry' @@ -224,6 +225,9 @@ async function executeDetachedCodexPaneRestart( tabId: tab.id, leafId, replacesPtyId: ptyId, + ...(restartReplacesLayoutRoot(currentState.terminalLayoutsByTabId[tab.id], leafId) + ? { placement: { kind: 'root' as const } } + : {}), ...(tab.shellOverride ? { shellOverride: tab.shellOverride } : {}), ...(projectRuntime ? { projectRuntime } : {}), initiallyHidden: true @@ -307,27 +311,23 @@ function reopenCurrentCodexRestartPrompt(located: LocatedCodexPane, replacedPtyI } } -function layoutRootContainsLeaf( - node: TerminalPaneLayoutNode | null | undefined, +// Why: mount replays panes from the root — a root that doesn't name this leaf +// mints a fresh one and silently orphans the replacement PTY. Rewriting is +// only safe when this is the tab's sole bound pane; a split keeps its root. +function restartReplacesLayoutRoot( + layout: TerminalLayoutSnapshot | undefined, leafId: string ): boolean { - if (!node) { - return false - } - if (node.type === 'leaf') { - return node.leafId === leafId - } - return layoutRootContainsLeaf(node.first, leafId) || layoutRootContainsLeaf(node.second, leafId) + return ( + !collectLeafIds(layout?.root).includes(leafId) && + Object.keys(layout?.ptyIdsByLeafId ?? {}).every((id) => id === leafId) + ) } function rebindCodexPaneLayoutLeaf(tabId: string, leafId: string, newPtyId: string): void { const store = useAppStore.getState() const layout = store.terminalLayoutsByTabId[tabId] - const boundLeafIds = Object.keys(layout?.ptyIdsByLeafId ?? {}) - // Why: mount replays panes from the root — a root that doesn't name this leaf - // mints a fresh one and silently orphans the replacement PTY. Rewriting is - // only safe when this is the tab's sole bound pane; a split keeps its root. - if (!layoutRootContainsLeaf(layout?.root, leafId) && boundLeafIds.every((id) => id === leafId)) { + if (restartReplacesLayoutRoot(layout, leafId)) { store.setTabLayout( tabId, singlePaneLayoutSnapshot(leafId, newPtyId, layout?.titlesByLeafId?.[leafId] ?? null) diff --git a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts index 667865deb7e1..7961fed61ec2 100644 --- a/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts +++ b/src/renderer/src/components/terminal-pane/ipc-pty-spawn-request.ts @@ -85,6 +85,7 @@ export async function spawnIpcPty( worktreeId, tabId, leafId, + placement, shellOverride, projectRuntime, terminalColorQueryReplies, @@ -131,6 +132,7 @@ export async function spawnIpcPty( worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), + ...(placement && !admittedSessionId ? { placement } : {}), ...(replacesPtyId ? { replacesPtyId } : {}), ...(shellOverride ? { shellOverride } : {}), ...(projectRuntime ? { projectRuntime } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 1f0935b8085b..adf9f511a2c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -237,6 +237,18 @@ describe('connectPanePty', () => { expect(retain()).toBe(false) }) + it('carries the pane placement onto its transport', async () => { + const { connectPanePty } = await import('./pty-connection') + transportFactoryQueue.push(createMockTransport()) + const placement = { kind: 'new-tab' } as const + const deps = createDeps({ tabId: 'tab-placement', placement }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks() + + expect(createdTransportOptions[0]).toMatchObject({ placement }) + }) + it('fresh-spawns normally when the pane worktree is not being deleted', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 6e8d340b3a37..b45b6478725d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -9,6 +9,7 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { SetupSplitDirection } from '../../../../shared/worktree/launch-types' import type { AgentProviderSessionMetadata, @@ -60,6 +61,7 @@ export type PtyConnectionDeps = { cwd?: string /** Delays a fresh split's spawn without delaying its renderer pane. */ cwdPromise?: Promise + placement?: TerminalPanePlacement /** Input handed off from a predecessor mount of the same deferred split. */ preconnectInput?: readonly PtyPreconnectInputEntry[] /** Captures newly retained input for a remount-safe deferred split handoff. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts index aa226a3a78ee..d6f8a6de9c62 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts @@ -82,6 +82,7 @@ export function installPtyInputRecovery(session: ConnectPanePtySession): void { // and the main-side guard short-circuits. tabId: session.deps.tabId, leafId: session.pane.leafId, + ...(session.deps.placement ? { placement: session.deps.placement } : {}), activate: session.deps.isActiveRef.current && session.deps.isVisibleRef.current, ...(session.shellOverride ? { shellOverride: session.shellOverride } : {}), ...(session.projectRuntime ? { projectRuntime: session.projectRuntime } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts index 4d6b0aa911b1..ca8d7a816b4a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts @@ -306,6 +306,26 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('sends placement on a fresh spawn but not on a session reattach', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const placement = { + kind: 'split', + parentLeafId: '11111111-1111-4111-8111-111111111111', + direction: 'horizontal' + } as const + + const fresh = createIpcPtyTransport({ placement }) + await fresh.connect({ url: '', callbacks: {} }) + expect(spawn).toHaveBeenLastCalledWith(expect.objectContaining({ placement })) + fresh.disconnect() + + const reattach = createIpcPtyTransport({ placement }) + await reattach.connect({ url: '', callbacks: {}, sessionId: 'session-1' }) + expect(spawn).toHaveBeenLastCalledWith(expect.not.objectContaining({ placement })) + reattach.disconnect() + }) + it('returns startup cwd fallback metadata to the connection layer', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 4b2de188fce3..c45457dfbc1e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -13,6 +13,7 @@ import type { EventProps } from '../../../../shared/telemetry-events' import type { TerminalInputKind } from '../../../../shared/terminal-input-kind' import type { TerminalOscColorQueryReplyColors } from '../../../../shared/terminal-osc-color-reply' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { ExecutionHostId } from '../../../../shared/execution-host' import type { PtyDataMeta } from './pty-dispatcher' import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime-terminal-multiplexer' @@ -280,6 +281,8 @@ export type IpcPtyTransportOptions = { worktreeId?: string tabId?: string leafId?: string + /** Sent on fresh spawns only; a reattach names a PTY whose leaf main already knows. */ + placement?: TerminalPanePlacement activate?: boolean shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index f422f99fb537..4e0d7bd960cd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -21,6 +21,7 @@ import type { PtyPreconnectInputEntry } from './pty-preconnect-input-buffer' import { guardParserHandler } from './terminal-parser-handler-guard' import { isPaneReplaying } from './replay-guard' import { connectPanePty } from './pty-connection' +import { completePaneSpawnPlacement } from './terminal-pane-spawn-placement' import { createQueuedStartupConsumer, resolvePaneSeedCwd, @@ -202,6 +203,15 @@ export function createTerminalPaneCreatedHandler( : {}), ...(effectiveSpawnHints?.cwd ? { cwd: effectiveSpawnHints.cwd } : {}), ...(effectiveSpawnHints?.cwdPromise ? { cwdPromise: effectiveSpawnHints.cwdPromise } : {}), + ...(effectiveSpawnHints?.placement + ? { + placement: completePaneSpawnPlacement(effectiveSpawnHints.placement, { + worktreeId: deps.worktreeId, + tabId: deps.tabId, + container: deps.containerRef.current + }) + } + : {}), restoredPtyIdByLeafId: effectiveSpawnHints?.ptyId ? { ...ptyDeps.restoredPtyIdByLeafId, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts new file mode 100644 index 000000000000..eec7df33470b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' + +const TAB = { + id: 'tab-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: 'Build', + color: '#f97316', + sortOrder: 1, + createdAt: 42, + shellOverride: 'wsl.exe', + isPinned: true +} + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ tabsByWorktree: { 'wt-1': [TAB] } }) } +})) +// Why: happy-dom has no canvas, so xterm cannot open; placement never touches the terminal. +vi.mock('@/lib/pane-manager/pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('@/lib/pane-manager/pane-manager') +const { completePaneSpawnPlacement } = await import('./terminal-pane-spawn-placement') + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +describe('completePaneSpawnPlacement', () => { + const root = document.createElement('div') + + afterEach(() => { + root.replaceChildren() + }) + + it('adds the tab creation fields to a new tab', () => { + const placement = completePaneSpawnPlacement( + { kind: 'new-tab' }, + { worktreeId: 'wt-1', tabId: 'tab-1', container: root } + ) + + expect(placement).toEqual({ + kind: 'new-tab', + row: { + title: 'Terminal 2', + defaultTitle: 'Terminal 2', + customTitle: 'Build', + color: '#f97316', + createdAt: 42, + shellOverride: 'wsl.exe' + } + }) + }) + + it('leaves a new tab bare when the store no longer has it', () => { + expect( + completePaneSpawnPlacement( + { kind: 'new-tab' }, + { worktreeId: 'wt-1', tabId: 'tab-gone', container: root } + ) + ).toEqual({ kind: 'new-tab' }) + }) + + it('adds the mounted tree after the split', () => { + let placement: TerminalPanePlacement | undefined + const manager = new PaneManager(root, { + linkOpenHint: () => '', + onPaneCreated: (_pane, hints) => { + placement = hints?.placement + ? completePaneSpawnPlacement(hints.placement, { + worktreeId: 'wt-1', + tabId: 'tab-1', + container: root + }) + : undefined + } + }) + const first = manager.createInitialPane({ leafId: LEAF_A }) + manager.splitPane(first.id, 'horizontal', { leafId: LEAF_B, ratio: 0.3 }) + + expect(placement).toEqual({ + kind: 'split', + parentLeafId: LEAF_A, + direction: 'horizontal', + ratio: 0.3, + proposedRoot: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B }, + ratio: 0.3 + } + }) + manager.destroy() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts new file mode 100644 index 000000000000..920e27865464 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-spawn-placement.ts @@ -0,0 +1,23 @@ +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' +import { useAppStore } from '@/store' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' +import { serializePaneTree } from './layout-serialization' + +/** Adds what only the mounted tab knows: the new tab's row, or the tree after the split. */ +export function completePaneSpawnPlacement( + placement: TerminalPanePlacement, + pane: { worktreeId: string; tabId: string; container: HTMLElement | null } +): TerminalPanePlacement { + if (placement.kind === 'new-tab') { + const tab = useAppStore + .getState() + .tabsByWorktree[pane.worktreeId]?.find((entry) => entry.id === pane.tabId) + return tab ? { ...placement, row: terminalPanePlacementRow(tab) } : placement + } + if (placement.kind === 'split') { + const top = pane.container?.firstElementChild + const proposedRoot = top instanceof HTMLElement ? serializePaneTree(top) : null + return proposedRoot ? { ...placement, proposedRoot } : placement + } + return placement +} diff --git a/src/renderer/src/lib/background-pane-exit-output.ts b/src/renderer/src/lib/background-pane-exit-output.ts new file mode 100644 index 000000000000..5d077313bf1e --- /dev/null +++ b/src/renderer/src/lib/background-pane-exit-output.ts @@ -0,0 +1,46 @@ +import { + registerEagerPtyBuffer, + type EagerPtyHandle +} from '@/components/terminal-pane/pty-dispatcher' +import { useAppStore } from '@/store' + +function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void { + const store = useAppStore.getState() + const layout = store.terminalLayoutsByTabId[tabId] + if (!layout) { + return + } + const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout + const nextPtyIds = { ...existingPtyIds } + delete nextPtyIds[leafId] + const trimmedOutput = output.trim() ? output : '' + store.setTabLayout(tabId, { + ...rest, + ...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}), + ...(trimmedOutput + ? { + buffersByLeafId: { + ...existingBuffers, + [leafId]: output + } + } + : existingBuffers + ? { buffersByLeafId: existingBuffers } + : {}) + }) +} + +// Why the incarnation: a relay-recycled id can hold the previous owner's exit, and draining that +// into this handler tears the pane down seconds after it launched. +export function registerBackgroundPaneBuffer( + tabId: string, + leafId: string, + pane: { ptyId: string; incarnationId?: string } +): void { + let eagerBuffer: EagerPtyHandle | null = null + const onExit = (exitPtyId: string): void => { + persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '') + useAppStore.getState().clearTabPtyId(tabId, exitPtyId) + } + eagerBuffer = registerEagerPtyBuffer(pane.ptyId, onExit, pane.incarnationId) +} diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 23606a83776e..d665133b910d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -129,7 +129,8 @@ describe('launchAgentBackgroundSession', () => { }), connectionId: null, worktreeId: 'wt-1', - tabId + tabId, + placement: { kind: 'new-tab', row: { customTitle: 'Nightly audit' } } }) ) const paneKey = expectStableAgentBackgroundPaneSpawn(mockSpawn) diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 3e997d32e51e..ed6c58d82bcf 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -191,6 +191,8 @@ export async function launchAgentBackgroundSession( worktreeId, tabId: reservedTabId, leafId, + // Why no launchAgent: the adopted tab is created without one, and the row must match it. + placement: { kind: 'new-tab', ...(title ? { row: { customTitle: title } } : {}) }, telemetry: { agent_kind: tuiAgentToAgentKind(agent), launch_source: launchSource ?? 'unknown', diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts index ea463464751e..88ab463e60d7 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.test.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.test.ts @@ -87,6 +87,16 @@ describe('launchWorktreeBackgroundTerminals', () => { state.tabsByWorktree['wt-1'].push(tab) return tab }) + // Placement reads the row back from the store, so title and color writes must land there. + const patchTab = (tabId: string, patch: { customTitle?: string; color?: string }): void => { + state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].map((tab) => + tab.id === tabId ? { ...tab, ...patch } : tab + ) + } + mockSetTabCustomTitle.mockImplementation((tabId: string, customTitle: string) => + patchTab(tabId, { customTitle }) + ) + mockSetTabColor.mockImplementation((tabId: string, color: string) => patchTab(tabId, { color })) mockCloseTab.mockImplementation((tabId: string) => { state.tabsByWorktree['wt-1'] = state.tabsByWorktree['wt-1'].filter((tab) => tab.id !== tabId) }) @@ -136,7 +146,11 @@ describe('launchWorktreeBackgroundTerminals', () => { connectionId: null, worktreeId: 'wt-1', tabId: 'tab-1', - leafId: '00000000-0000-4000-8000-000000000001' + leafId: '00000000-0000-4000-8000-000000000001', + placement: { + kind: 'new-tab', + row: expect.objectContaining({ customTitle: 'Dev', color: '#f97316' }) + } }) ) expect(mockSpawn).toHaveBeenNthCalledWith( @@ -145,7 +159,8 @@ describe('launchWorktreeBackgroundTerminals', () => { command: 'bash /tmp/setup.sh', env: expect.objectContaining({ ORCA_WORKTREE_PATH: '/repo/worktree' }), tabId: 'tab-2', - leafId: '00000000-0000-4000-8000-000000000002' + leafId: '00000000-0000-4000-8000-000000000002', + placement: { kind: 'new-tab', row: expect.objectContaining({ customTitle: 'Setup' }) } }) ) expect(mockUpdateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-1') @@ -168,7 +183,8 @@ describe('launchWorktreeBackgroundTerminals', () => { 1, expect.objectContaining({ tabId: 'tab-1', - leafId: '00000000-0000-4000-8000-000000000001' + leafId: '00000000-0000-4000-8000-000000000001', + placement: { kind: 'new-tab', row: expect.any(Object) } }) ) expect(mockSpawn.mock.calls[0]?.[0]).not.toHaveProperty('command') @@ -177,7 +193,18 @@ describe('launchWorktreeBackgroundTerminals', () => { expect.objectContaining({ command: 'bash /tmp/setup.sh', tabId: 'tab-1', - leafId: '00000000-0000-4000-8000-000000000002' + leafId: '00000000-0000-4000-8000-000000000002', + placement: { + kind: 'split', + parentLeafId: '00000000-0000-4000-8000-000000000001', + direction: 'horizontal', + proposedRoot: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000001' }, + second: { type: 'leaf', leafId: '00000000-0000-4000-8000-000000000002' } + } + } }) ) expect(mockSetTabLayout).toHaveBeenLastCalledWith( diff --git a/src/renderer/src/lib/launch-worktree-background-terminals.ts b/src/renderer/src/lib/launch-worktree-background-terminals.ts index 791504d6948a..b5b31b5f97c8 100644 --- a/src/renderer/src/lib/launch-worktree-background-terminals.ts +++ b/src/renderer/src/lib/launch-worktree-background-terminals.ts @@ -1,12 +1,10 @@ -import { - registerEagerPtyBuffer, - type EagerPtyHandle -} from '@/components/terminal-pane/pty-dispatcher' import { createBrowserUuid } from '@/lib/browser-uuid' import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers' import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal' +import { registerBackgroundPaneBuffer } from '@/lib/background-pane-exit-output' +import { terminalPanePlacementRow } from '@/lib/terminal-pane-placement-row' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { makePaneKey } from '../../../shared/stable-pane-id' @@ -14,7 +12,11 @@ import { buildSetupRunnerCommand, getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command' -import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode +} from '../../../shared/terminal-tab-types' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' import type { WorktreeDefaultTabsLaunch, WorktreeSetupLaunch @@ -62,6 +64,19 @@ function buildPaneEnv( } } +function buildSplitRoot( + firstLeafId: string, + secondLeafId: string, + direction: 'horizontal' | 'vertical' +): TerminalPaneLayoutNode { + return { + type: 'split', + direction, + first: { type: 'leaf', leafId: firstLeafId }, + second: { type: 'leaf', leafId: secondLeafId } + } +} + function buildSplitLayout( first: BackgroundPane, second: BackgroundPane, @@ -69,12 +84,7 @@ function buildSplitLayout( secondTitle: string ): TerminalLayoutSnapshot { return { - root: { - type: 'split', - direction, - first: { type: 'leaf', leafId: first.leafId }, - second: { type: 'leaf', leafId: second.leafId } - }, + root: buildSplitRoot(first.leafId, second.leafId, direction), activeLeafId: first.leafId, expandedLeafId: null, ptyIdsByLeafId: { @@ -87,43 +97,6 @@ function buildSplitLayout( } } -function persistExitedPaneOutput(tabId: string, leafId: string, output: string): void { - const store = useAppStore.getState() - const layout = store.terminalLayoutsByTabId[tabId] - if (!layout) { - return - } - const { ptyIdsByLeafId: existingPtyIds, buffersByLeafId: existingBuffers, ...rest } = layout - const nextPtyIds = { ...existingPtyIds } - delete nextPtyIds[leafId] - const trimmedOutput = output.trim() ? output : '' - store.setTabLayout(tabId, { - ...rest, - ...(Object.keys(nextPtyIds).length > 0 ? { ptyIdsByLeafId: nextPtyIds } : {}), - ...(trimmedOutput - ? { - buffersByLeafId: { - ...existingBuffers, - [leafId]: output - } - } - : existingBuffers - ? { buffersByLeafId: existingBuffers } - : {}) - }) -} - -// Why the incarnation: a relay-recycled id can hold the previous owner's exit, and draining that -// into this handler tears the pane down seconds after it launched. -function registerBackgroundPaneBuffer(tabId: string, leafId: string, pane: SpawnedPane): void { - let eagerBuffer: EagerPtyHandle | null = null - const onExit = (exitPtyId: string): void => { - persistExitedPaneOutput(tabId, leafId, eagerBuffer?.flush() ?? '') - useAppStore.getState().clearTabPtyId(tabId, exitPtyId) - } - eagerBuffer = registerEagerPtyBuffer(pane.ptyId, onExit, pane.incarnationId) -} - function buildSetupCommand(setup: WorktreeSetupLaunch): string { // Why: background setup tabs can launch later, so they must reuse the same shell chosen when the runner was written. return buildSetupRunnerCommand( @@ -141,6 +114,7 @@ async function spawnPane(args: { connectionId: string | null tabId: string leafId: string + placement: TerminalPanePlacement command?: string env?: Record }): Promise { @@ -153,7 +127,8 @@ async function spawnPane(args: { connectionId: args.connectionId, worktreeId: args.worktree.id, tabId: args.tabId, - leafId: args.leafId + leafId: args.leafId, + placement: args.placement }) return { ptyId: result.id, @@ -180,6 +155,8 @@ async function createBackgroundTab(args: { const leafId = createBrowserUuid() store.setTabLayout(tab.id, singlePaneLayoutSnapshot(leafId)) + const created = + useAppStore.getState().tabsByWorktree[args.worktree.id]?.find(({ id }) => id === tab.id) ?? tab let pane: SpawnedPane try { pane = await spawnPane({ @@ -187,6 +164,7 @@ async function createBackgroundTab(args: { connectionId: args.connectionId, tabId: tab.id, leafId, + placement: { kind: 'new-tab', row: terminalPanePlacementRow(created) }, command: args.launch.command, env: args.launch.env }) @@ -223,6 +201,12 @@ async function addSetupSplit(args: { connectionId: args.connectionId, tabId: args.tab.tabId, leafId: setupLeafId, + placement: { + kind: 'split', + parentLeafId: args.tab.primary.leafId, + direction: args.direction, + proposedRoot: buildSplitRoot(args.tab.primary.leafId, setupLeafId, args.direction) + }, command: buildSetupCommand(args.setup), env: args.setup.envVars }) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts index 129685726da4..b86a28fe054f 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -30,7 +30,7 @@ export function createInitialManagedPane( focusPanePreservingOverlays(pane) } - host.publishPaneCreated(pane) + host.publishPaneCreated(pane, { placement: { kind: 'new-tab' } }) return toPublicPane(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts b/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts new file mode 100644 index 000000000000..348cbb1e5a9b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-spawn-placement.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { PaneManagerOptions } from './pane-manager-types' +import { serializePaneTree } from '@/components/terminal-pane/layout-serialization' + +// Why: happy-dom has no canvas, so xterm cannot open; placement never touches the terminal. +vi.mock('./pane-lifecycle', async (importOriginal) => { + const actual: object = await importOriginal() + return { ...actual, openTerminal: vi.fn() } +}) + +const { PaneManager } = await import('./pane-manager') + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' +const LEAF_C = '33333333-3333-4333-8333-333333333333' + +describe('PaneManager spawn placement hints', () => { + let root: HTMLDivElement + let onPaneCreated: Mock> + let manager: InstanceType + + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', () => 1) + vi.stubGlobal('cancelAnimationFrame', () => {}) + root = document.createElement('div') + document.body.appendChild(root) + onPaneCreated = vi.fn() + manager = new PaneManager(root, { linkOpenHint: () => '', onPaneCreated }) + }) + + afterEach(() => { + manager.destroy() + root.remove() + vi.unstubAllGlobals() + }) + + it('marks a tab first pane as a new tab', () => { + const pane = manager.createInitialPane({ leafId: LEAF_A }) + + expect(onPaneCreated).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ id: pane.id, leafId: LEAF_A }), + { placement: { kind: 'new-tab' } } + ) + }) + + it('names the split pane and direction alongside the other spawn hints', () => { + const first = manager.createInitialPane({ leafId: LEAF_A }) + onPaneCreated.mockClear() + + manager.splitPane(first.id, 'horizontal', { leafId: LEAF_B, cwd: '/repo/app' }) + + expect(onPaneCreated).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ leafId: LEAF_B }), + { + cwd: '/repo/app', + placement: { kind: 'split', parentLeafId: LEAF_A, direction: 'horizontal' } + } + ) + }) + + it('publishes a split placed before a mounted subtree with its final order', () => { + const first = manager.createInitialPane({ leafId: LEAF_A }) + manager.splitPane(first.id, 'vertical', { leafId: LEAF_B }) + const treesAtCreation: unknown[] = [] + onPaneCreated.mockReset().mockImplementation(() => { + const top = root.firstElementChild + treesAtCreation.push(top instanceof HTMLElement ? serializePaneTree(top) : null) + }) + + manager.splitPaneAroundLeafIds([LEAF_A, LEAF_B], first.id, 'horizontal', { + leafId: LEAF_C, + placement: 'before' + }) + + expect(onPaneCreated).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ leafId: LEAF_C }), + { placement: { kind: 'split', parentLeafId: LEAF_A, direction: 'horizontal' } } + ) + // The pane-created handler serializes proposedRoot from the DOM, so the order must be final then. + expect(treesAtCreation).toEqual([ + { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_C }, + second: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_A }, + second: { type: 'leaf', leafId: LEAF_B } + } + } + ]) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 80531b7fecf7..30fe50108445 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -9,6 +9,7 @@ import type { WebglAddon } from '@xterm/addon-webgl' import type { SerializeAddon } from '@xterm/addon-serialize' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalPanePlacement } from '../../../../shared/terminal-pane-placement' import type { PaneLayoutEditIntent } from '../../../../shared/rpc-contract/session-tabs-schemas-params' import type { TerminalWebglAutoDecision } from './terminal-webgl-auto-policy' @@ -26,9 +27,11 @@ export type PaneSpawnHints = { cwd?: string cwdPromise?: Promise ptyId?: string + /** Where the pane's leaf sits; main reads it only for a leaf it does not know yet. */ + placement?: TerminalPanePlacement } -export type PaneSplitOptions = PaneSpawnHints & { +export type PaneSplitOptions = Omit & { ratio?: number leafId?: string } diff --git a/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts b/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts index 93c4a67f97b7..d335ce829a46 100644 --- a/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-overlay-focus.test.ts @@ -72,7 +72,7 @@ describe.each(['initial', 'active'] as const)('%s pane focus', (operation) => { function focus(f: ReturnType, requested = true) { if (operation === 'initial') { createInitialManagedPane(f.host, { focus: requested }) - expect(f.publishPaneCreated).toHaveBeenCalledWith(f.pane) + expect(f.publishPaneCreated).toHaveBeenCalledWith(f.pane, { placement: { kind: 'new-tab' } }) } else { f.root.append(f.container) f.manager.setActivePane(f.pane.id, { focus: requested }) diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts index 098c5eb2c15e..cd3b728cd24d 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.test.ts @@ -156,7 +156,10 @@ describe('splitManagedPane', () => { expect(result?.id).toBe(newPane.id) expect(setActivePaneId).toHaveBeenCalledWith(newPane.id) expect(newPane.terminal.focus).toHaveBeenCalledOnce() - expect(publishPaneCreated).toHaveBeenCalledWith(newPane, { cwdPromise }) + expect(publishPaneCreated).toHaveBeenCalledWith(newPane, { + cwdPromise, + placement: { kind: 'split', parentLeafId: existingPane.leafId, direction: 'vertical' } + }) }) it('prepares every pane under a moved mounted subtree for split reparenting', () => { @@ -213,7 +216,7 @@ describe('splitManagedPane', () => { newPane.container, true, expect.anything(), - undefined + { ratio: undefined, newPaneFirst: undefined } ) expect(scheduleSplitScrollRestore).toHaveBeenCalledTimes(2) expect(scheduleSplitScrollRestore).toHaveBeenNthCalledWith( diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 37ccf92b385d..6cf53fe9a2df 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -34,6 +34,8 @@ type SplitManagedPaneArgs = { direction: 'vertical' | 'horizontal' opts?: PaneSplitOptions sourceContainer?: HTMLElement + /** Before publishing, so the pane-created tree already has the final order. */ + newPaneFirst?: boolean panes: Map root: HTMLElement styleOptions: PaneStyleOptions @@ -65,9 +67,12 @@ export function splitManagedPane(args: SplitManagedPaneArgs): ManagedPane | null const movedPaneStates = prepareMovedPanesForSplit(existingContainer, existing, args.panes) - wrapInSplit(existingContainer, newPane.container, isVertical, divider, args.opts) + wrapInSplit(existingContainer, newPane.container, isVertical, divider, { + ratio: args.opts?.ratio, + newPaneFirst: args.newPaneFirst + }) args.setActivePaneId(newPane.id) - openSplitPane(args, newPane, args.opts?.cwd) + openSplitPane(args, newPane, existing.leafId, args.opts?.cwd) for (const movedPaneState of movedPaneStates) { scheduleSplitScrollRestore( @@ -139,6 +144,7 @@ function findManagedPanesInContainer( function openSplitPane( args: SplitManagedPaneArgs, newPane: ManagedPaneInternal, + parentLeafId: string, cwd?: string ): void { openTerminal(newPane, { @@ -151,12 +157,17 @@ function openSplitPane( updateMultiPaneState(args.getDragCallbacks()) // Why: forward one-shot spawn/adoption hints so the new pane inherits the // source cwd for local splits or attaches a runtime-spawned PTY for web splits. - const spawnHints = { + args.publishPaneCreated(newPane, { ...(cwd ? { cwd } : {}), ...(args.opts?.cwdPromise ? { cwdPromise: args.opts.cwdPromise } : {}), - ...(args.opts?.ptyId ? { ptyId: args.opts.ptyId } : {}) - } - args.publishPaneCreated(newPane, Object.keys(spawnHints).length > 0 ? spawnHints : undefined) + ...(args.opts?.ptyId ? { ptyId: args.opts.ptyId } : {}), + placement: { + kind: 'split', + parentLeafId, + direction: args.direction, + ...(args.opts?.ratio !== undefined ? { ratio: args.opts.ratio } : {}) + } + }) args.managerOptions.onLayoutChanged?.() } diff --git a/src/renderer/src/lib/pane-manager/pane-subtree-split.ts b/src/renderer/src/lib/pane-manager/pane-subtree-split.ts index 37e75cfd3e64..e6be21fae506 100644 --- a/src/renderer/src/lib/pane-manager/pane-subtree-split.ts +++ b/src/renderer/src/lib/pane-manager/pane-subtree-split.ts @@ -47,11 +47,12 @@ export function splitPaneAroundMountedSubtree( if (!sourceContainer) { return null } - const createdPane = splitManagedPane({ + return splitManagedPane({ paneId: args.fallbackPaneId, direction: args.direction, opts: args.opts, sourceContainer, + newPaneFirst: args.opts?.placement === 'before', panes: args.panes, root: args.root, styleOptions: args.styleOptions, @@ -63,15 +64,6 @@ export function splitPaneAroundMountedSubtree( setActivePaneId: args.setActivePaneId, isDestroyed: args.isDestroyed }) - if (!createdPane || args.opts?.placement !== 'before') { - return createdPane - } - - const createdInternal = args.panes.get(createdPane.id) - if (createdInternal) { - placeCreatedPaneBeforeSource(sourceContainer, createdInternal.container) - } - return createdPane } function findMountedSubtreeContainer( @@ -125,23 +117,3 @@ function setsEqual(left: ReadonlySet, right: ReadonlySet): boole } return true } - -function placeCreatedPaneBeforeSource( - sourceContainer: HTMLElement, - createdContainer: HTMLElement -): boolean { - const split = createdContainer.parentElement - if (!split || sourceContainer.parentElement !== split) { - return false - } - const divider = Array.from(split.children).find( - (child): child is HTMLElement => - child instanceof HTMLElement && child.classList.contains('pane-divider') - ) - if (!divider) { - return false - } - - split.replaceChildren(createdContainer, divider, sourceContainer) - return true -} diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 778adfb6c15e..6b435177d2b0 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -245,14 +245,14 @@ export function removeDividers(parent: HTMLElement): void { /** * Create a flex split wrapper that replaces `existingContainer` in the DOM, - * then places [existing] [divider] [new] inside it. + * then places [existing] [divider] [new] inside it ([new] first when `newPaneFirst`). */ export function wrapInSplit( existingContainer: HTMLElement, newContainer: HTMLElement, isVertical: boolean, divider: HTMLElement, - opts?: { ratio?: number } + opts?: { ratio?: number; newPaneFirst?: boolean } ): void { const parent = existingContainer.parentElement if (!parent) { @@ -292,7 +292,9 @@ export function wrapInSplit( // Replace existing with split in the DOM, then build children parent.replaceChild(split, existingContainer) - split.appendChild(existingContainer) - split.appendChild(divider) - split.appendChild(newContainer) + if (opts?.newPaneFirst) { + split.append(newContainer, divider, existingContainer) + } else { + split.append(existingContainer, divider, newContainer) + } } diff --git a/src/renderer/src/lib/terminal-pane-placement-row.ts b/src/renderer/src/lib/terminal-pane-placement-row.ts new file mode 100644 index 000000000000..53ae435a200d --- /dev/null +++ b/src/renderer/src/lib/terminal-pane-placement-row.ts @@ -0,0 +1,29 @@ +import type { TerminalPaneNewTabRow } from '../../../shared/terminal-pane-placement' +import type { TerminalTab } from '../../../shared/terminal-tab-types' + +export function terminalPanePlacementRow(tab: TerminalTab): TerminalPaneNewTabRow { + const { + title, + defaultTitle, + customTitle, + color, + createdAt, + startupCwd, + shellOverride, + quickCommandLabel, + launchAgent, + viewMode + } = tab + return { + title, + defaultTitle, + customTitle, + color, + createdAt, + startupCwd, + shellOverride, + quickCommandLabel, + launchAgent, + viewMode + } +} diff --git a/src/shared/terminal-pane-placement.test.ts b/src/shared/terminal-pane-placement.test.ts index 71e1c027ea48..1ff106cf846c 100644 --- a/src/shared/terminal-pane-placement.test.ts +++ b/src/shared/terminal-pane-placement.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { parseTerminalPanePlacement } from './terminal-pane-placement' const LEAF = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' describe('parseTerminalPanePlacement', () => { it('accepts each kind', () => { @@ -11,6 +12,49 @@ describe('parseTerminalPanePlacement', () => { expect(parseTerminalPanePlacement({ kind: 'root' })).toEqual({ kind: 'root' }) }) + it('keeps a new tab row and a split ratio and proposed tree', () => { + const newTab = { + kind: 'new-tab', + row: { + title: 'Terminal 2', + customTitle: null, + color: '#f97316', + createdAt: 1, + viewMode: 'chat' + } + } + const split = { + kind: 'split', + parentLeafId: LEAF, + direction: 'horizontal', + ratio: 0.3, + proposedRoot: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: OTHER_LEAF }, + second: { type: 'leaf', leafId: LEAF }, + ratio: 0.3 + } + } + expect(parseTerminalPanePlacement(newTab)).toEqual(newTab) + expect(parseTerminalPanePlacement(split)).toEqual(split) + }) + + it('drops only a malformed optional field', () => { + expect(parseTerminalPanePlacement({ kind: 'new-tab', row: { createdAt: 'now' } })).toEqual({ + kind: 'new-tab' + }) + expect( + parseTerminalPanePlacement({ + kind: 'split', + parentLeafId: LEAF, + direction: 'vertical', + ratio: 'half', + proposedRoot: { type: 'tree' } + }) + ).toEqual({ kind: 'split', parentLeafId: LEAF, direction: 'vertical' }) + }) + it('strips fields a newer sender adds', () => { expect(parseTerminalPanePlacement({ kind: 'root', future: 1 })).toEqual({ kind: 'root' }) expect(parseTerminalPanePlacement({ kind: 'new-tab', size: { cols: 120, rows: 40 } })).toEqual({ diff --git a/src/shared/terminal-pane-placement.ts b/src/shared/terminal-pane-placement.ts index 361922446ff3..9627f6d9edc6 100644 --- a/src/shared/terminal-pane-placement.ts +++ b/src/shared/terminal-pane-placement.ts @@ -1,7 +1,28 @@ import { z } from 'zod' import { isTerminalLeafId } from './stable-pane-id' +import { terminalPaneLayoutNodeSchema, terminalTabSchema } from './workspace-session-schema' -const NewTabPlacement = z.object({ kind: z.literal('new-tab') }) +// The creation fields of the tab the renderer made, so main need not wait for its save. +const NewTabRow = terminalTabSchema + .pick({ + title: true, + defaultTitle: true, + customTitle: true, + color: true, + createdAt: true, + startupCwd: true, + quickCommandLabel: true, + launchAgent: true, + viewMode: true + }) + .extend({ shellOverride: z.string().optional() }) + .partial() + +// Optional fields degrade alone: a malformed one is dropped, not the whole placement. +const NewTabPlacement = z.object({ + kind: z.literal('new-tab'), + row: NewTabRow.optional().catch(undefined) +}) const SplitPlacement = z.object({ kind: z.literal('split'), @@ -9,7 +30,10 @@ const SplitPlacement = z.object({ .string() .max(128) .refine((value): boolean => isTerminalLeafId(value)), - direction: z.enum(['horizontal', 'vertical']) + direction: z.enum(['horizontal', 'vertical']), + ratio: z.number().optional().catch(undefined), + /** The tab's tree after the split, which `parentLeafId` alone can't express (subtrees, before/after). */ + proposedRoot: terminalPaneLayoutNodeSchema.optional().catch(undefined) }) // An existing tab whose layout is still empty. @@ -21,6 +45,8 @@ const TerminalPanePlacementSchema = z.discriminatedUnion('kind', [ RootPlacement ]) +export type TerminalPaneNewTabRow = z.infer + /** Which tab and leaf a new PTY joins; a binding reads it only for a leaf it does not know yet. */ export type TerminalPanePlacement = z.infer diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 5274cb8be3e9..bf67f580a247 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -51,7 +51,7 @@ const workspaceKeySchema = z.custom( // Why: z.lazy + type annotation keeps the recursive inference working without // forcing zod to resolve the whole tree at definition time. Discriminated on `type` because a // plain union re-tries the leaf branch for every split node of every restored terminal layout. -const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(() => +export const terminalPaneLayoutNodeSchema: z.ZodType = z.lazy(() => z.discriminatedUnion('type', [ z.object({ type: z.literal('leaf'), @@ -82,7 +82,7 @@ const terminalLayoutSnapshotSchema = z.object({ // ─── Terminal tab (legacy) ────────────────────────────────────────── -const terminalTabSchema = z.object({ +export const terminalTabSchema = z.object({ id: terminalTabIdSchema, ptyId: z.string().nullable(), worktreeId: z.string(), From 7bea20d83aab7b98cec39a848ae7d5142a44ea61 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:46:12 -0400 Subject: [PATCH 111/394] test(terminal): pin layout invariants the mirror refactor must keep (#26073) --- .../terminal-sleep-quit-resume.test.ts | 167 ++++++ .../terminal-topology-model.test.ts | 529 ++++++++++++++++++ .../terminal-topology-profile-fixture.ts | 46 ++ ...etup-split-activation-single-owner.test.ts | 284 ++++++++++ .../terminal-exited-pane-liveness.test.ts | 76 +++ 5 files changed, 1102 insertions(+) create mode 100644 src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-model.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts create mode 100644 src/renderer/src/lib/setup-split-activation-single-owner.test.ts create mode 100644 src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts diff --git a/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts new file mode 100644 index 000000000000..297a99a3eb0b --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-sleep-quit-resume.test.ts @@ -0,0 +1,167 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { buildAgentResumeStartupPlan } from '../../../shared/tui-agent-resume-startup' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import type { Store } from '../loading-store/store' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID as WORKTREE, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +// The mirror refactor moves sleeping records into main's own commits and makes window saves +// presentation-only; a slept agent must still be resumable after quit and relaunch. + +const TAB = 'tab-agent' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PTY = `${WORKTREE}@@0a1b2c3d` +const PANE_KEY = `${TAB}:${LEAF}` + +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function record(origin: 'worktree-sleep' | 'quit', capturedAt: number): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: TAB, + worktreeId: WORKTREE, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${capturedAt}` }, + prompt: 'finish the task', + state: 'waiting', + capturedAt, + updatedAt: capturedAt, + origin + } +} + +/** The window's session after it spawned an agent pane, as its debounced save sends it. */ +function windowSessionWithAgentPane(store: Store): WorkspaceSessionState { + const session = structuredClone(store.getWorkspaceSession()) + session.tabsByWorktree = { + ...session.tabsByWorktree, + [WORKTREE]: [ + { + id: TAB, + ptyId: PTY, + worktreeId: WORKTREE, + title: 'codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + ] + } + session.terminalLayoutsByTabId = { + ...session.terminalLayoutsByTabId, + [TAB]: { + root: { type: 'leaf', leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF]: PTY } + } + } + return session +} + +async function storeWithAgentPane() { + const directory = mkdtempSync(join(tmpdir(), 'orca-sleep-quit-resume-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = windowSessionWithAgentPane(store) + store.setWorkspaceSession(structuredClone(window)) + await expect( + store.persistPtyBinding({ worktreeId: WORKTREE, tabId: TAB, leafId: LEAF, ptyId: PTY }) + ).resolves.toBe(true) + return { directory, store, window } +} + +/** What main holds for the slept pane, and what the window would show from its slice. */ +function heldForPane(store: Store) { + const session = store.getWorkspaceSession() + const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, WORKTREE) + return { + record: session.sleepingAgentSessionsByPaneKey?.[PANE_KEY], + sliceRecord: slice.sleeping[PANE_KEY], + tabs: slice.tabs.map((tab) => tab.id), + binding: slice.layouts[TAB]?.ptyIdsByLeafId?.[LEAF] + } +} + +function resumeCommand(held: SleepingAgentSessionRecord | undefined): string | undefined { + return held + ? buildAgentResumeStartupPlan({ + agent: held.agent, + providerSession: held.providerSession, + cmdOverrides: {}, + platform: 'linux' + })?.launchCommand + : undefined +} + +describe('sleep → quit → resume', () => { + it('keeps the quit capture over the periodic one through quit and relaunch', async () => { + const { directory, store, window } = await storeWithAgentPane() + // The agent is slept; the periodic save carries the first capture. + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('worktree-sleep', 1) } + store.setWorkspaceSession(structuredClone(window)) + // Quit: the window's quit capture rides only on the synchronous before-unload stage. + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 2) } + store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + + const relaunched = await reopenTopologyStore(store, directory) + try { + const expected = { + record: record('quit', 2), + sliceRecord: record('quit', 2), + tabs: [TAB], + binding: PTY + } + expect(heldForPane(relaunched)).toEqual(expected) + // The relaunched window's first save is its hydrated copy; the record stays. + relaunched.setWorkspaceSession(structuredClone(relaunched.getWorkspaceSession())) + expect(heldForPane(relaunched)).toEqual(expected) + expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-2'") + } finally { + await relaunched.freezeWritesAsync() + } + }) + + it('keeps a record that only the before-unload stage carried', async () => { + const { directory, store, window } = await storeWithAgentPane() + window.sleepingAgentSessionsByPaneKey = { [PANE_KEY]: record('quit', 3) } + store.stageWorkspaceSessionBeforeUnload(structuredClone(window)) + + const relaunched = await reopenTopologyStore(store, directory) + try { + expect(heldForPane(relaunched)).toEqual({ + record: record('quit', 3), + sliceRecord: record('quit', 3), + tabs: [TAB], + binding: PTY + }) + expect(resumeCommand(heldForPane(relaunched).record)).toBe("codex 'resume' 'session-3'") + } finally { + await relaunched.freezeWritesAsync() + } + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-model.test.ts b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts new file mode 100644 index 000000000000..04a48908298f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-model.test.ts @@ -0,0 +1,529 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mulberry32 } from '../../../shared/agent-tui-ansi-fuzz-stream' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { TerminalPaneLayoutNode, TerminalTab } from '../../../shared/terminal-tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { projectTerminalTopologySlice } from '../../runtime/terminal-topology-projection' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import { collectTerminalLeafOwners, isSameTerminal } from './terminal-owner-invariants' +import { closeLeafOrTab } from './terminal-topology-commit' +import { + emptyTerminalSessionProfile, + FIXTURE_FOLDER_WORKTREE_ID, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +/** + * Model test for terminal layout as main holds it. Seeded gesture sequences run against main's + * real Store, written the way today's desktop window writes them; a plain reference model says + * what main must hold after each one. Making the window a mirror of main changes who writes, not + * this outcome, so every seed must stay green through that refactor. + * Replay one seed: FUZZ_SEED= FUZZ_ITERATIONS=1 pnpm test . + */ + +const WORKTREES = [ + FIXTURE_GIT_WORKTREE_ID, + FIXTURE_FOLDER_WORKTREE_ID, + FLOATING_TERMINAL_WORKTREE_ID +] +const FIRST_SEED = Number(process.env.FUZZ_SEED ?? 0x9417) +const ITERATIONS = Number(process.env.FUZZ_ITERATIONS ?? 6) +const OPS_PER_SEED = 40 + +type ModelPane = { ptyId: string | undefined; sleeping: boolean } +/** What main must hold: tab → leaf → binding, per worktree, in tab order. */ +type Model = Map }> + +const directories: string[] = [] + +function makeIds(random: () => number) { + const hex = (length: number): string => + Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join('') + return { + leaf: () => `${hex(8)}-${hex(4)}-4${hex(3)}-8${hex(3)}-${hex(12)}`, + tab: () => `tab-${hex(8)}`, + pty: (worktreeId: string) => `${worktreeId}@@${hex(8)}` + } +} + +function leafIds(node: TerminalPaneLayoutNode | null | undefined): string[] { + if (!node) { + return [] + } + return node.type === 'leaf' ? [node.leafId] : [...leafIds(node.first), ...leafIds(node.second)] +} + +function withoutLeaf(node: TerminalPaneLayoutNode, leafId: string): TerminalPaneLayoutNode | null { + if (node.type === 'leaf') { + return node.leafId === leafId ? null : node + } + const first = withoutLeaf(node.first, leafId) + const second = withoutLeaf(node.second, leafId) + if (!first || !second) { + return first ?? second + } + return { ...node, first, second } +} + +function sleepingRecord( + worktreeId: string, + tabId: string, + leafId: string +): SleepingAgentSessionRecord { + return { + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId, + agent: 'codex', + providerSession: { key: 'session_id', id: `session-${leafId}` }, + prompt: 'model', + state: 'waiting', + capturedAt: 1, + updatedAt: 1, + origin: 'quit' + } +} + +/** The desktop window's copy of the session: it authors tabs and layouts and saves them whole. */ +class WindowSession { + constructor(public session: WorkspaceSessionState) {} + + addTab(worktreeId: string, tabId: string, leafId: string): void { + const tabs = this.session.tabsByWorktree[worktreeId] ?? [] + const tab: TerminalTab = { + id: tabId, + ptyId: null, + worktreeId, + title: `Terminal ${tabs.length + 1}`, + customTitle: null, + color: null, + sortOrder: tabs.length, + createdAt: 1 + } + this.session.tabsByWorktree = { ...this.session.tabsByWorktree, [worktreeId]: [...tabs, tab] } + this.setLayout(tabId, { type: 'leaf', leafId }, {}) + } + + setLayout(tabId: string, root: TerminalPaneLayoutNode, ptyIdsByLeafId: Record) { + this.session.terminalLayoutsByTabId = { + ...this.session.terminalLayoutsByTabId, + [tabId]: { + root, + activeLeafId: leafIds(root)[0] ?? null, + expandedLeafId: null, + ptyIdsByLeafId + } + } + } + + bind(tabId: string, leafId: string, ptyId: string): void { + const layout = this.session.terminalLayoutsByTabId[tabId] + if (layout?.root) { + this.setLayout(tabId, layout.root, { ...layout.ptyIdsByLeafId, [leafId]: ptyId }) + } + this.setTabPtyId(tabId, ptyId) + } + + /** `clearTabPtyId` on exit: the tab's live id goes; the layout keeps the binding as a resume hint. */ + setTabPtyId(tabId: string, ptyId: string | null): void { + this.session.tabsByWorktree = Object.fromEntries( + Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab) => (tab.id === tabId ? { ...tab, ptyId } : tab)) + ]) + ) + } + + removeTab(tabId: string): void { + this.session.tabsByWorktree = Object.fromEntries( + Object.entries(this.session.tabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.filter((tab) => tab.id !== tabId) + ]) + ) + const { [tabId]: _removed, ...layouts } = this.session.terminalLayoutsByTabId + void _removed + this.session.terminalLayoutsByTabId = layouts + this.setSleeping( + Object.fromEntries( + Object.entries(this.session.sleepingAgentSessionsByPaneKey ?? {}).filter( + ([paneKey]) => !paneKey.startsWith(`${tabId}:`) + ) + ) + ) + } + + setSleeping(records: Record): void { + this.session.sleepingAgentSessionsByPaneKey = records + } + + snapshot(): WorkspaceSessionState { + return structuredClone(this.session) + } +} + +/** Fails on a terminal bound to two leaves or a leaf in two tabs, naming both owners. */ +function expectOwnerInvariants(session: WorkspaceSessionState, context: string): void { + const owners = collectTerminalLeafOwners({ hostId: LOCAL_EXECUTION_HOST_ID, session }) + const describe = (owner: (typeof owners)[number]): string => + `${owner.worktreeId} ${owner.tab.id}:${owner.leafId} → ${owner.ptyId ?? 'unbound'}` + const breaches: string[] = [] + for (const [index, left] of owners.entries()) { + for (const right of owners.slice(index + 1)) { + if (left.leafId === right.leafId && left.tab.id !== right.tab.id) { + breaches.push(`leaf in two tabs: ${describe(left)} | ${describe(right)}`) + } else if (left.leafId !== right.leafId && isSameTerminal(left, right)) { + breaches.push(`terminal on two leaves: ${describe(left)} | ${describe(right)}`) + } + } + } + expect(breaches, context).toEqual([]) +} + +/** Main's projected slices, reduced to what the model states. */ +function projectedTopology(session: WorkspaceSessionState) { + return Object.fromEntries( + WORKTREES.map((worktreeId) => { + const slice = projectTerminalTopologySlice(session, LOCAL_EXECUTION_HOST_ID, worktreeId) + const tabs = slice.tabs.map((tab) => { + const layout = slice.layouts[tab.id] + return { + tabId: tab.id, + leaves: Object.fromEntries( + leafIds(layout?.root).map((leafId) => [leafId, layout?.ptyIdsByLeafId?.[leafId]]) + ) + } + }) + return [worktreeId, { tabs, sleeping: Object.keys(slice.sleeping).sort() }] + }) + ) +} + +function expectedTopology(model: Model) { + return Object.fromEntries( + WORKTREES.map((worktreeId) => { + const entries = [...model.entries()].filter(([, tab]) => tab.worktreeId === worktreeId) + const tabs = entries.map(([tabId, tab]) => ({ + tabId, + leaves: Object.fromEntries([...tab.leaves].map(([leafId, pane]) => [leafId, pane.ptyId])) + })) + const sleeping = entries + .flatMap(([tabId, tab]) => + [...tab.leaves] + .filter(([, pane]) => pane.sleeping) + .map(([leafId]) => `${tabId}:${leafId}`) + ) + .sort() + return [worktreeId, { tabs, sleeping }] + }) + ) +} + +type Op = + | 'new_tab' + | 'split' + | 'close_pane' + | 'close_tab' + | 'move_pane' + | 'pty_exit' + | 'respawn' + | 'sleep' + | 'wake' + | 'restart' + | 'quit_restart' + +const OPS: readonly Op[] = [ + 'new_tab', + 'new_tab', + 'split', + 'split', + 'close_pane', + 'close_tab', + 'move_pane', + 'pty_exit', + 'respawn', + 'sleep', + 'wake', + 'restart', + 'quit_restart' +] + +async function runSeed(seed: number): Promise { + const random = mulberry32(seed) + const pick = (items: readonly T[]): T | undefined => + items.length === 0 ? undefined : items[Math.floor(random() * items.length)] + const ids = makeIds(random) + const directory = mkdtempSync(join(tmpdir(), 'orca-topology-model-')) + directories.push(directory) + let store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = new WindowSession(structuredClone(store.getWorkspaceSession())) + const model: Model = new Map() + const log: string[] = [] + const save = (): void => store.setWorkspaceSession(window.snapshot()) + const panes = () => + [...model].flatMap(([tabId, tab]) => + [...tab.leaves].map(([leafId, pane]) => ({ tabId, leafId, tab, pane })) + ) + const splitTabs = () => [...model].filter(([, tab]) => tab.leaves.size > 1) + + const bind = async (worktreeId: string, tabId: string, leafId: string, ptyId: string) => { + await expect(store.persistPtyBinding({ worktreeId, tabId, leafId, ptyId })).resolves.toBe(true) + window.bind(tabId, leafId, ptyId) + model.get(tabId)!.leaves.set(leafId, { ptyId, sleeping: false }) + } + + /** `closeTerminalSurfaceFromRenderer`: the window removed the surface; main commits the close. */ + const commitClose = async (worktreeId: string, target: TerminalSurfaceCloseTarget) => { + const refusal = await store.runDurableMutation( + closeLeafOrTab({ + worktreeId, + target, + options: { allowMissing: true, force: true, closedByLayoutOwner: true, reason: 'user' }, + requestedSession: store.getWorkspaceSession(), + ownerMatches: () => true, + hostId: () => LOCAL_EXECUTION_HOST_ID, + getSession: (hostId) => store.getWorkspaceSession(hostId), + setSession: (session, hostId) => store.setWorkspaceSession(session, hostId), + onClosed: () => {} + }) + ) + expect(refusal).toBeUndefined() + } + + const reopen = async (stageQuit: boolean): Promise => { + if (stageQuit) { + store.stageWorkspaceSessionBeforeUnload(window.snapshot()) + } + store = await reopenTopologyStore(store, directory) + window.session = structuredClone(store.getWorkspaceSession()) + } + + for (let step = 0; step < OPS_PER_SEED; step++) { + const op = pick(OPS)! + switch (op) { + case 'new_tab': { + const worktreeId = pick(WORKTREES)! + const tabId = ids.tab() + const leafId = ids.leaf() + const ptyId = ids.pty(worktreeId) + // pty:spawn can beat the window's debounced layout save. + const spawnFirst = random() < 0.5 + log.push( + `${op} ${worktreeId} ${tabId}:${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}` + ) + model.set(tabId, { worktreeId, leaves: new Map() }) + window.addTab(worktreeId, tabId, leafId) + if (!spawnFirst) { + save() + } + await bind(worktreeId, tabId, leafId, ptyId) + save() + break + } + case 'split': { + const target = pick(panes()) + if (!target) { + continue + } + const leafId = ids.leaf() + const ptyId = ids.pty(target.tab.worktreeId) + const spawnFirst = random() < 0.5 + const direction = random() < 0.5 ? 'vertical' : 'horizontal' + log.push( + `${op} ${target.tabId}:${target.leafId} → ${leafId} ${ptyId}${spawnFirst ? ' spawn-first' : ''}` + ) + const layout = window.session.terminalLayoutsByTabId[target.tabId]! + window.setLayout( + target.tabId, + { + type: 'split', + direction, + first: layout.root!, + second: { type: 'leaf', leafId } + }, + layout.ptyIdsByLeafId ?? {} + ) + if (!spawnFirst) { + save() + } + await bind(target.tab.worktreeId, target.tabId, leafId, ptyId) + save() + break + } + case 'close_pane': { + const [tabId, tab] = pick(splitTabs()) ?? [] + const leafId = tab && pick([...tab.leaves.keys()]) + if (!tabId || !tab || !leafId) { + continue + } + log.push(`${op} ${tabId}:${leafId}`) + const layout = window.session.terminalLayoutsByTabId[tabId]! + const { [leafId]: _closed, ...bindings } = layout.ptyIdsByLeafId ?? {} + void _closed + window.setLayout(tabId, withoutLeaf(layout.root!, leafId)!, bindings) + const { [`${tabId}:${leafId}`]: _record, ...sleeping } = + window.session.sleepingAgentSessionsByPaneKey ?? {} + void _record + window.setSleeping(sleeping) + tab.leaves.delete(leafId) + await commitClose(tab.worktreeId, { kind: 'pane', tabId, leafId }) + save() + break + } + case 'close_tab': { + const tabId = pick([...model.keys()]) + if (!tabId) { + continue + } + log.push(`${op} ${tabId}`) + const { worktreeId } = model.get(tabId)! + window.removeTab(tabId) + model.delete(tabId) + await commitClose(worktreeId, { kind: 'tab', tabId }) + save() + break + } + case 'move_pane': { + const [sourceTabId, source] = pick(splitTabs()) ?? [] + const leafId = source && pick([...source.leaves.keys()]) + if (!sourceTabId || !source || !leafId) { + continue + } + const targetTabId = ids.tab() + const pane = source.leaves.get(leafId)! + log.push(`${op} ${sourceTabId}:${leafId} → ${targetTabId}`) + await expect( + store.moveTerminalLeafToNewTab({ + worktreeId: source.worktreeId, + sourceTabId, + targetTabId, + leafId, + ptyId: pane.ptyId ?? null + }) + ).resolves.toEqual({ status: 'moved', ptyId: pane.ptyId ?? null }) + // The window then shows what main moved, as the drag-out does today. + const layout = window.session.terminalLayoutsByTabId[sourceTabId]! + const { [leafId]: movedPty, ...bindings } = layout.ptyIdsByLeafId ?? {} + window.setLayout(sourceTabId, withoutLeaf(layout.root!, leafId)!, bindings) + window.addTab(source.worktreeId, targetTabId, leafId) + if (movedPty) { + window.bind(targetTabId, leafId, movedPty) + } + const records = { ...window.session.sleepingAgentSessionsByPaneKey } + const record = records[`${sourceTabId}:${leafId}`] + if (record) { + delete records[`${sourceTabId}:${leafId}`] + records[`${targetTabId}:${leafId}`] = { + ...record, + paneKey: `${targetTabId}:${leafId}`, + tabId: targetTabId + } + window.setSleeping(records) + } + source.leaves.delete(leafId) + model.set(targetTabId, { + worktreeId: source.worktreeId, + leaves: new Map([[leafId, pane]]) + }) + save() + break + } + case 'pty_exit': { + const target = pick(panes().filter(({ pane }) => pane.ptyId)) + if (!target) { + continue + } + log.push(`${op} ${target.tabId}:${target.leafId}`) + // Main keeps an exited pane's binding; only the window's live id goes. + window.setTabPtyId(target.tabId, null) + save() + break + } + case 'respawn': { + const target = pick(panes()) + if (!target) { + continue + } + const ptyId = ids.pty(target.tab.worktreeId) + log.push(`${op} ${target.tabId}:${target.leafId} ${ptyId}`) + const sleeping = target.pane.sleeping + await bind(target.tab.worktreeId, target.tabId, target.leafId, ptyId) + target.tab.leaves.get(target.leafId)!.sleeping = sleeping + save() + break + } + case 'sleep': + case 'wake': { + const target = pick(panes().filter(({ pane }) => pane.sleeping === (op === 'wake'))) + if (!target) { + continue + } + log.push(`${op} ${target.tabId}:${target.leafId}`) + const paneKey = `${target.tabId}:${target.leafId}` + const records = { ...window.session.sleepingAgentSessionsByPaneKey } + if (op === 'sleep') { + records[paneKey] = sleepingRecord(target.tab.worktreeId, target.tabId, target.leafId) + } else { + delete records[paneKey] + } + window.setSleeping(records) + target.pane.sleeping = op === 'sleep' + save() + break + } + case 'restart': + case 'quit_restart': { + log.push(op) + await reopen(op === 'quit_restart') + break + } + } + const context = `seed ${seed}, step ${step}:\n${log.join('\n')}` + const session = store.getWorkspaceSession() + expectOwnerInvariants(session, context) + expect(projectedTopology(session), context).toEqual(expectedTopology(model)) + } + + // The saved profile, read back cold, holds the same layout. + await reopen(false) + const context = `seed ${seed}, after reopen:\n${log.join('\n')}` + expectOwnerInvariants(store.getWorkspaceSession(), context) + expect(projectedTopology(store.getWorkspaceSession()), context).toEqual(expectedTopology(model)) + await store.freezeWritesAsync() + return log +} + +describe('terminal layout model (main session)', () => { + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('keeps one tab per pane, one pane per terminal, and the expected layout across random gestures', async () => { + const ranOps = new Set() + for (let iteration = 0; iteration < ITERATIONS; iteration++) { + const log = await runSeed(FIRST_SEED + iteration) + for (const entry of log) { + ranOps.add(entry.split(' ')[0]!) + } + } + // Precondition: the seeds reached every gesture, so a green run is not a quiet one. + if (ITERATIONS >= 6) { + expect([...ranOps].sort()).toEqual([...new Set(OPS)].sort()) + } + }, 60_000) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts b/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts new file mode 100644 index 000000000000..d6bb5fdda26f --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-profile-fixture.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { DelayedAuthority } from '../loading-store/profile-state-delayed-authority-fixture' +import { Store } from '../loading-store/store' +import { buildProfileStateCutoverFixture } from '../profile-state-cutover-fixture' +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' + +/** A worktree of the fixture's registered repo, so load keeps its rows. */ +export const FIXTURE_GIT_WORKTREE_ID = 'repo-local::/fixture/local' +export const FIXTURE_FOLDER_WORKTREE_ID = 'repo-local::/fixture/local::workspace:folder-1' + +/** The cutover fixture profile with no terminal session yet. */ +export function emptyTerminalSessionProfile(): string { + const state = buildProfileStateCutoverFixture() + return JSON.stringify({ + ...state, + // A fixed install id: load mints a random one when it is missing. + settings: { + ...state.settings, + telemetry: { existedBeforeTelemetryRelease: true, optedIn: null, installId: 'install-1' } + }, + workspaceSession: getDefaultWorkspaceSession(), + workspaceSessionsByHostId: {} + }) +} + +/** Opens a real Store on SQLite in `directory`, seeding it first when `seed` is given. */ +export async function openTopologyStore(directory: string, seed?: string): Promise { + const inner = new ProfileStateSqliteAuthority(join(directory, 'profile-state.db'), 'topology') + if (seed) { + inner.writeSerializedState(Buffer.from(seed)) + } + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new DelayedAuthority(inner) + }) + await store.flushPendingOrThrowAsync() + return store +} + +/** Quit and relaunch: drain pending writes, close, and load the profile cold. */ +export async function reopenTopologyStore(store: Store, directory: string): Promise { + await store.flushPendingOrThrowAsync() + await store.freezeWritesAsync() + return openTopologyStore(directory) +} diff --git a/src/renderer/src/lib/setup-split-activation-single-owner.test.ts b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts new file mode 100644 index 000000000000..0ac2a9d5f647 --- /dev/null +++ b/src/renderer/src/lib/setup-split-activation-single-owner.test.ts @@ -0,0 +1,284 @@ +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { useAppStore } from '@/store' +import { registerTerminalPresentationIpcBridge } from '@/hooks/ipc-events/terminal-presentation-ipc-bridge' +import { getDefaultSettings } from '../../../shared/constants' +import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' +import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types' +import { activateAndRevealWorktree } from './worktree-activation' +import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' + +/** + * STA-9417, converted from the natural repro: a host-created worktree whose setup script runs in a + * split of the (unmounted) primary tab, then the first activation of that worktree. When main's + * split reveal does not reach the window, the activation sweep mints a second tab for the setup + * PTY, so two saved panes own one terminal and one of them freezes. + */ + +const initialState = useAppStore.getState() +const TAB_B = 'b3f1c1de-5e0f-4a51-9d0c-7a8e2a9f6b11' +const PRIMARY_LEAF = '11111111-1111-4111-8111-111111111111' +const SETUP_LEAF = '22222222-2222-4222-8222-222222222222' + +type CreateTerminalListener = Parameters[0] + +function baseState(): Partial { + const worktree = makeWorktree() + return { + repos: [ + { + id: worktree.repoId, + path: path.join(path.sep, 'workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + terminalLayoutsByTabId: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + settings: { + ...getDefaultSettings(path.join(path.sep, 'home')), + agentCmdOverrides: {}, + defaultTuiAgent: 'codex', + setupScriptLaunchMode: 'split-vertical' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } +} + +/** The host's census: both panes are unmounted, so it reports them unowned with their record. */ +function orphanRow(ptyId: string, leafId: string): RuntimeTerminalSummary { + const worktree = makeWorktree() + return { + handle: `handle-${leafId}`, + ptyId, + orphaned: true, + recordedPaneKey: `${TAB_B}:${leafId}`, + worktreeId: worktree.id, + worktreePath: worktree.path, + branch: worktree.branch ?? 'main', + tabId: `pty:${ptyId}`, + leafId: `pty:${ptyId}`, + title: 'Terminal', + connected: true, + writable: true, + lastOutputAt: null, + preview: '' + } +} + +function stubHost( + primaryPtyId: string, + setupPtyId: string +): { reveal: () => CreateTerminalListener } { + const worktree = makeWorktree() + let listener: CreateTerminalListener | undefined + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.list') { + return { + ok: true, + result: { + worktree: worktree.id, + publicationEpoch: 'sta-9417', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + } + } + if (method === 'terminal.list') { + return { + ok: true, + result: { + terminals: [orphanRow(primaryPtyId, PRIMARY_LEAF), orphanRow(setupPtyId, SETUP_LEAF)], + truncated: false, + hostScope: { hostIds: ['local'], omittedHostIds: [] } + } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const listSessions = vi.fn(async () => + [primaryPtyId, setupPtyId].map((id) => ({ + id, + cwd: worktree.path, + title: 'Terminal', + agentOwnership: 'absent' as const + })) + ) + vi.stubGlobal('window', { + api: { + runtime: { call: runtimeCall }, + pty: { listSessions }, + ui: { + onCreateTerminal: (next: CreateTerminalListener) => { + listener = next + return () => {} + }, + onRequestTerminalTabMount: () => () => {}, + replyTerminalCreate: vi.fn() + } + }, + dispatchEvent: vi.fn() + }) + return { + reveal: () => { + if (!listener) { + throw new Error('presentation bridge did not register') + } + return listener + } + } +} + +/** Main's background reveal of the primary terminal: the tab exists here but never mounted. */ +function seedRevealedPrimaryTab(worktreeId: string, primaryPtyId: string): void { + useAppStore.getState().createTab(worktreeId, undefined, undefined, { + id: TAB_B, + initialLeafId: PRIMARY_LEAF, + initialPtyId: primaryPtyId, + activate: false, + recordInteraction: false + }) +} + +function leafIdsInOrder(node: TerminalPaneLayoutNode | null): string[] { + if (!node) { + return [] + } + return node.type === 'leaf' + ? [node.leafId] + : [...leafIdsInOrder(node.first), ...leafIdsInOrder(node.second)] +} + +function setupSplitReveal( + requestId: string, + worktreeId: string, + setupPtyId: string +): Parameters[0] { + return { + requestId, + worktreeId, + ptyId: setupPtyId, + activate: false, + surfaceOwner: false, + tabId: TAB_B, + leafId: SETUP_LEAF, + splitFromLeafId: PRIMARY_LEAF, + splitDirection: 'vertical' + } +} + +/** ptyId → every tab:leaf in the worktree that holds it. */ +function ownersByPty(worktreeId: string): Map { + const state = useAppStore.getState() + const owners = new Map() + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + const layout = state.terminalLayoutsByTabId[tab.id] + for (const leafId of leafIdsInOrder(layout?.root ?? null)) { + const ptyId = layout?.ptyIdsByLeafId?.[leafId] + if (ptyId) { + owners.set(ptyId, [...(owners.get(ptyId) ?? []), `${tab.id}:${leafId}`]) + } + } + } + return owners +} + +/** The sidebar click, then the first-activation sweep the terminal watcher runs (STA-9417 step 2). */ +async function activateFirstTime(worktreeId: string): Promise { + activateAndRevealWorktree(worktreeId) + await expect(gateWorktreeAgentActivation(worktreeId)).resolves.toBe('adopted') +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState(initialState, true) +}) + +describe('STA-9417: setup split of an unmounted tab, then first activation', () => { + async function runScenario(revealSplit: boolean): Promise { + const worktree = makeWorktree() + const primaryPtyId = `${worktree.id}@@primary` + const setupPtyId = `${worktree.id}@@setup` + useAppStore.setState(baseState()) + const host = stubHost(primaryPtyId, setupPtyId) + registerTerminalPresentationIpcBridge([]) + seedRevealedPrimaryTab(worktree.id, primaryPtyId) + + if (revealSplit) { + host.reveal()(setupSplitReveal('reveal-setup-split', worktree.id, setupPtyId)) + } + await activateFirstTime(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs.map((tab) => tab.id)).toEqual([TAB_B]) + expect(Object.fromEntries(ownersByPty(worktree.id))).toEqual({ + [primaryPtyId]: [`${TAB_B}:${PRIMARY_LEAF}`], + [setupPtyId]: [`${TAB_B}:${SETUP_LEAF}`] + }) + } + + it('ends with one tab and one owner per terminal when main reveals the split', async () => { + await runScenario(true) + }) + + // STA-9417: fails until the mirror refactor (B2-4) pushes main's split before the sweep runs. + it.fails('ends with one tab and one owner per terminal when the split reveal never arrives', async () => { + await runScenario(false) + }) + + // STA-9417: fails until the mirror refactor (B2-4); the odd runs mint the duplicate. + it.fails('stays duplicate-free across 8 create-then-activate cycles, with or without the reveal', async () => { + let duplicates = 0 + for (let run = 0; run < 8; run++) { + const worktree = makeWorktree() + const primaryPtyId = `${worktree.id}@@primary-${run}` + const setupPtyId = `${worktree.id}@@setup-${run}` + useAppStore.setState(initialState, true) + useAppStore.setState(baseState()) + const host = stubHost(primaryPtyId, setupPtyId) + registerTerminalPresentationIpcBridge([]) + seedRevealedPrimaryTab(worktree.id, primaryPtyId) + // Odd runs lose the reveal, as the natural repro did. + if (run % 2 === 0) { + host.reveal()(setupSplitReveal(`reveal-${run}`, worktree.id, setupPtyId)) + } + await activateFirstTime(worktree.id) + duplicates += [...ownersByPty(worktree.id).values()].filter( + (owners) => owners.length > 1 + ).length + duplicates += (useAppStore.getState().tabsByWorktree[worktree.id] ?? []).length - 1 + } + expect(duplicates).toBe(0) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts new file mode 100644 index 000000000000..141d3141b641 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-exited-pane-liveness.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { createTestStore, makeTab, makeWorktree } from '../slices/store-test-helpers' +import { createStoreSessionMockApi } from '../slices/store-session-test-harness' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +createStoreSessionMockApi() + +// "Is this pane running" reads the liveness map; an exit leaves the pane's layout binding in place +// for its one stale reattach. The mirror refactor keeps this split: liveness stays in the window, +// the binding becomes main's. + +const WORKTREE = 'repo1::/path/wt1' +const LEFT = '11111111-1111-4111-8111-111111111111' +const RIGHT = '22222222-2222-4222-8222-222222222222' + +function splitLayout(): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEFT }, + second: { type: 'leaf', leafId: RIGHT } + }, + activeLeafId: LEFT, + expandedLeafId: null, + ptyIdsByLeafId: { [LEFT]: 'pty-left', [RIGHT]: 'pty-right' } + } +} + +function storeWithSplitTab() { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt1', hostId: 'local' })] + }, + tabsByWorktree: { + [WORKTREE]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE, ptyId: 'pty-right' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] }, + terminalLayoutsByTabId: { 'tab-1': splitLayout() } + }) + return store +} + +describe('exited pane in a split', () => { + it('drops only the exited PTY from liveness and keeps every layout binding', () => { + const store = storeWithSplitTab() + + // Ctrl-D in the left pane. + store.getState().clearTabPtyId('tab-1', 'pty-left') + + const state = store.getState() + expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-right']) + expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(true) + expect(state.terminalLayoutsByTabId['tab-1']).toEqual(splitLayout()) + }) + + it('reads the tab as not running once both panes exit, with the bindings still saved', () => { + const store = storeWithSplitTab() + + store.getState().clearTabPtyId('tab-1', 'pty-left') + store.getState().clearTabPtyId('tab-1', 'pty-right') + + const state = store.getState() + expect(tabHasLivePty(state.ptyIdsByTabId, 'tab-1')).toBe(false) + expect(state.tabsByWorktree[WORKTREE]?.[0]?.ptyId).toBeNull() + expect(state.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ + [LEFT]: 'pty-left', + [RIGHT]: 'pty-right' + }) + }) +}) From d68f3bb5b2a90a341e58160ce621e7e60cf4a184 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:47:53 -0400 Subject: [PATCH 112/394] fix(ssh): bind reattached SSH panes into the target's own session partition (#26088) * fix(ssh): bind reattached SSH panes into the target's own session partition A relay reattach persisted the pane binding into the local partition, minting a minimal copy of every reattached SSH tab there. When nothing saved over it (a reconnect with no window open), the next startup kept that copy and skipped the SSH partition's rows for the workspace: its open editor tabs were missing for a launch and its agent-resume records were dropped (STA-9544). Bind into ssh:, where the spawn bound the same pane. * test(e2e): run the reattach home-partition spec only in the Docker SSH lane * test(e2e): keep Electron running after its last window closes on Linux in the reattach spec * test(e2e): sync the SSH reattach spec on state, not sleeps Waits for the SSH partition to persist the open file and for main's SSH state to report the reattached connection, instead of fixed 3s/30s sleeps that could pass vacuously on a slow reconnect. --- .github/workflows/e2e.yml | 1 + config/scripts/ci-e2e-job-selection.mjs | 1 + config/scripts/run-ssh-docker-e2e.mjs | 1 + .../loading-store/pty-binding-refusals.ts | 2 +- .../terminal-owner-invariants.ts | 4 +- ...le-headless-mobile-session-browser-tabs.ts | 4 +- ...elay-session-reconnect-incarnation.test.ts | 2 + src/main/ssh/ssh-relay-session.ts | 20 +-- tests/e2e/ssh-reattach-home-partition.spec.ts | 169 ++++++++++++++++++ 9 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/ssh-reattach-home-partition.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index be2386e24471..5534d9599fac 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -328,6 +328,7 @@ jobs: . != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and . != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and . != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and + . != "tests/e2e/ssh-reattach-home-partition.spec.ts" and . != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and . != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and . != "tests/e2e/ssh-skill-installation.spec.ts" and diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index e2e207ce58bb..1e36d0799795 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -21,6 +21,7 @@ export const DOCKER_SSH_E2E_SPECS = [ 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reattach-home-partition.spec.ts', 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', 'tests/e2e/ssh-skill-installation.spec.ts', diff --git a/config/scripts/run-ssh-docker-e2e.mjs b/config/scripts/run-ssh-docker-e2e.mjs index 72fbbb9d44f2..58cac941f405 100644 --- a/config/scripts/run-ssh-docker-e2e.mjs +++ b/config/scripts/run-ssh-docker-e2e.mjs @@ -73,6 +73,7 @@ const result = spawnSync( 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts', 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts', 'tests/e2e/ssh-port-forward-lifecycle.spec.ts', + 'tests/e2e/ssh-reattach-home-partition.spec.ts', 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts', 'tests/e2e/ssh-restart-tab-accumulation.spec.ts', 'tests/e2e/ssh-skill-installation.spec.ts', diff --git a/src/main/persistence/loading-store/pty-binding-refusals.ts b/src/main/persistence/loading-store/pty-binding-refusals.ts index 6576ac3d0070..9de369b76829 100644 --- a/src/main/persistence/loading-store/pty-binding-refusals.ts +++ b/src/main/persistence/loading-store/pty-binding-refusals.ts @@ -25,7 +25,7 @@ export function ptyBindingIsRefused( bindingWorktreeId: string, paneKey: string, /** Every host partition: a close is recorded where the tab lived, which need not be where this - * binding lands (a relay reattach binds into `local`). */ + * binding lands (older relay reattaches left SSH panes in `local`). */ partitions: readonly TerminalSessionPartition[] ): boolean { if (args.expectedSourceBinding) { diff --git a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts index a5311486a06f..fad25738ea31 100644 --- a/src/main/persistence/terminal-topology/terminal-owner-invariants.ts +++ b/src/main/persistence/terminal-topology/terminal-owner-invariants.ts @@ -24,7 +24,7 @@ export type TerminalLeafOwner = { export type TerminalOwnerConflictReason = | 'pty_bound_to_other_leaf' | 'leaf_in_other_tab' - // Kept apart: the relay reattach writes SSH panes into `local`, so this may be one moved surface. + // Kept apart: older relay reattaches left SSH panes in `local`, so this may be one moved surface. | 'leaf_in_other_tab_on_other_host' /** `runtime:` partitions belong to a remote Orca server and are written only by its tab sync. */ @@ -83,7 +83,7 @@ export function isSameTerminal( /** * The saved leaf a binding into `hostId` would duplicate, if any. The same tab:leaf in two - * partitions is one surface: the relay reattach still binds an SSH pane into `local`. + * partitions is one surface: older relay reattaches left SSH panes in `local`. */ export function findTerminalBindingConflict( binding: { tabId: string; leafId: string; ptyId: string; incarnationId?: string }, diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index 6ce5f8a36502..146f87e095a1 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -127,8 +127,8 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or * binds one leaf in two tabs and orphans the PTY under the new one) and refuses the correct ones. * Same resolution `restoreReattachedPtyRuntime` already does for its own reattach fence. * - * Both workspace partitions are read because SSH spawns bind panes into `ssh:` while - * reattach binds into `local`; consulting one would report "nowhere" for a pane the other holds. + * Both workspace partitions are read because older builds' relay reattach left SSH panes in + * `local`; consulting one would report "nowhere" for a pane the other holds. */ protected findCurrentTerminalTabIdForLeaf(targetId: string, leafId: string): string | undefined { for (const leaf of this.leaves.values()) { diff --git a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts index ce223ae0b9c8..7231b20f6e44 100644 --- a/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts +++ b/src/main/ssh/ssh-relay-session-reconnect-incarnation.test.ts @@ -452,6 +452,8 @@ describe('SshRelaySession reconnect incarnation ordering', () => { mayReviveRetiredSurface: false, origin: 'relay_reattach' }) + // Into the pane's home partition: a `local` copy shadowed it at the next startup (STA-9544). + expect(mockStore.persistPtyBinding).toHaveBeenCalledWith(expect.any(Function), 'ssh:target-1') expect(vi.mocked(mockStore.persistPtyBinding).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(mockStore.markSshRemotePtyLeasesAttachedAsync).mock.invocationCallOrder[0]! ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d11587af4cf3..e7f7c60a15c0 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -2995,33 +2995,33 @@ export class SshRelaySession { if (lease?.worktreeId && lease.tabId && lease.leafId) { const { worktreeId, leafId, tabId: leaseTabId } = lease let tabId = lease.tabId + // The pane's home is this target's partition, where its spawn bound it. Binding into `local` + // left a copy there that startup then preferred over the home copy (STA-9544). + const hostId = toSshExecutionHostId(this.targetId) const bound = await this.store.persistPtyBinding(() => { if (!shouldContinue()) { return null } - const session = this.store.getWorkspaceSession?.() + const session = this.store.getWorkspaceSession?.(hostId) + // Rows an older build left in `local`; the renderer moves them home on its next save. + const legacySession = this.store.getWorkspaceSession?.() // The lease froze its tabId at write time; `detachTerminalPaneToTab` moves a live pane, so // trusting it would fence this reattach to the tab the pane LEFT and refuse a pane that // merely moved. Leaf is the identity, the tab is only where it currently sits. - // SSH spawns bind panes into `ssh:` while this reattach binds into `local`, so a - // fence that consulted only one partition would read "no pane" for a pane the other holds. - const hostSession = this.store.getWorkspaceSession?.(toSshExecutionHostId(this.targetId)) tabId = findTerminalTabIdForLeaf(session, leafId) ?? - findTerminalTabIdForLeaf(hostSession, leafId) ?? + findTerminalTabIdForLeaf(legacySession, leafId) ?? leaseTabId // Absence of the pane only means "the user closed it" once the persisted membership // speaks for this worktree. Before that it means the renderer has not published its // layout yet, and refusing there drops a tab the user still has — the regression that // reverted this fix twice. Losing a tab is worse than keeping a duplicate, so an // unauthoritative session still gets the creating write. - // Authority is read from `local` because that is the partition this write lands in — it - // is local's absence we would be interpreting. But a pane the other partition still holds - // is not gone, so it keeps its creating write: refusing there would strand a live pane + // A pane only `local` still holds is not gone either: refusing it would strand a live pane // behind a binding reattach can no longer reach. const mayCreate = !hasHostAuthoritativeTerminalMembership(session, worktreeId) || - findTerminalTabIdForLeaf(hostSession, leafId) !== undefined + findTerminalTabIdForLeaf(legacySession, leafId) !== undefined return { worktreeId: worktreeId, tabId, @@ -3032,7 +3032,7 @@ export class SshRelaySession { mayReviveRetiredSurface: false, origin: 'relay_reattach' as const } - }) + }, hostId) if (!shouldContinue()) { return 'missing-surface' } diff --git a/tests/e2e/ssh-reattach-home-partition.spec.ts b/tests/e2e/ssh-reattach-home-partition.spec.ts new file mode 100644 index 000000000000..9c5451ccfdf8 --- /dev/null +++ b/tests/e2e/ssh-reattach-home-partition.spec.ts @@ -0,0 +1,169 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { createRemoteTerminalTab } from './helpers/docker-ssh-relay-terminal-tabs' +import { + cleanupDockerSshRelayTarget, + DOCKER_SSH_RELAY_REMOTE_REPO_PATH, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection' +import { dropDockerSshRelayTransport } from './helpers/docker-ssh-relay-faults' +import { createRestartSession, readRestartRendererState } from './helpers/orca-restart' +import { readPersistedProfileState } from './helpers/persisted-profile-state' +import { toSshExecutionHostId } from '../../src/shared/execution-host' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' + +test.use({ seedTestRepo: false }) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function readWorkspace(page: Page, worktreeId: string): Promise { + return readRestartRendererState(() => + page.evaluate((id) => { + const state = window.__store?.getState() + if (!state) { + return null + } + return JSON.stringify({ + tabs: (state.tabsByWorktree[id] ?? []).length, + files: state.openFiles.filter((file) => file.worktreeId === id).map((f) => f.relativePath) + }) + }, worktreeId) + ).catch(() => null) +} + +function readPersistedSshOpenFiles( + userDataDir: string, + targetId: string, + worktreeId: string +): unknown[] { + const sessions = readPersistedProfileState(userDataDir).workspaceSessionsByHostId + const session = isRecord(sessions) ? sessions[toSshExecutionHostId(targetId)] : undefined + const files = + isRecord(session) && isRecord(session.openFilesByWorktree) + ? session.openFilesByWorktree[worktreeId] + : undefined + return Array.isArray(files) + ? files.map((file) => (isRecord(file) ? file.relativePath : undefined)) + : [] +} + +type PrivateInvokeHandlers = { + _invokeHandlers?: Map unknown> +} + +/** Main's SSH state, read without a renderer; it reports `connected` only once the relay's + * reattach has finished (the relay override holds `reconnecting` until then). */ +function readMainSshState(app: ElectronApplication, targetId: string): Promise { + return app.evaluate(({ ipcMain }, id) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test-only read of Electron's private invoke-handler map; the handler is called only if present. + const { _invokeHandlers: handlers } = ipcMain as unknown as PrivateInvokeHandlers + return handlers?.get('ssh:getState')?.({}, { targetId: id }) + }, targetId) +} + +/** + * A relay reattach used to bind the SSH pane into the `local` partition. With a window open the + * renderer's next save erased that copy within a second, but a reattach with no window (macOS + * keeps Orca running after its window closes) left it on disk. Startup then kept the `local` copy + * and skipped the SSH partition's rows for that workspace, so its open editor tabs were missing + * and its agent-resume records were dropped (STA-9544). + */ +test.describe('SSH relay reattach home partition', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH restore uses POSIX SSH tooling.') + + test('a reattach with no window open keeps the SSH workspace on the next launch', async (// oxlint-disable-next-line no-empty-pattern -- This restart test owns every Electron launch. + {}, testInfo) => { + test.setTimeout(600_000) + const restart = createRestartSession(testInfo) + let target: DockerSshRelayTarget | null = null + let app: ElectronApplication | null = null + try { + target = startDockerSshRelayTarget(testInfo) + const first = await restart.launch() + app = first.app + let page = first.page + await waitForSessionReady(page) + const remote = await connectDockerSshRelayTarget(page, target) + const { targetId, worktreeId } = remote + await expect.poll(() => waitForActiveWorktree(page), { timeout: 30_000 }).toBe(worktreeId) + await waitForActiveTerminalManager(page, 60_000) + await waitForActivePanePtyId(page, 60_000) + await createRemoteTerminalTab(page, worktreeId) + await page.evaluate( + ({ worktreeId, filePath }) => { + window.__store!.getState().openFile({ + filePath, + relativePath: 'README.md', + worktreeId, + language: 'markdown', + mode: 'edit' + }) + }, + { worktreeId, filePath: `${DOCKER_SSH_RELAY_REMOTE_REPO_PATH}/README.md` } + ) + const expected = JSON.stringify({ tabs: 2, files: ['README.md'] }) + await expect.poll(() => readWorkspace(page, worktreeId)).toBe(expected) + await expect + .poll(() => readPersistedSshOpenFiles(restart.userDataDir, targetId, worktreeId)) + .toEqual(['README.md']) + const before = await readMainSshState(first.app, targetId) + expect(before).toMatchObject({ + status: 'connected', + connectionGeneration: expect.any(Number) + }) + + await app.evaluate(({ app: electronApp, BrowserWindow }) => { + // Linux/Windows quit when the last window closes; keep running as macOS does. + electronApp.removeAllListeners('window-all-closed') + electronApp.on('window-all-closed', () => {}) + for (const window of BrowserWindow.getAllWindows()) { + window.close() + } + }) + expect(dropDockerSshRelayTransport(target)).toBeGreaterThan(0) + // Main reconnects on its own and reattaches both panes; no window is left to save over it. + await expect + .poll( + async () => { + const after = await readMainSshState(first.app, targetId) + return ( + isRecord(after) && + isRecord(before) && + after.status === 'connected' && + (after.providerEpoch !== before.providerEpoch || + after.connectionGeneration !== before.connectionGeneration) + ) + }, + { timeout: 120_000 } + ) + .toBe(true) + await restart.close(app) + app = null + const local = readPersistedProfileState(restart.userDataDir).workspaceSession + const localTabs = + isRecord(local) && isRecord(local.tabsByWorktree) ? local.tabsByWorktree : {} + expect.soft(localTabs[worktreeId] ?? [], 'SSH tabs in `local`').toEqual([]) + + const second = await restart.launch() + app = second.app + page = second.page + await expect + .poll(() => readWorkspace(page, worktreeId), { timeout: 60_000, intervals: [500] }) + .toBe(expected) + } finally { + if (app) { + await restart.close(app) + } + await restart.dispose() + cleanupDockerSshRelayTarget(target) + } + }) +}) From 990c62e6c65b2d2c7d099a58bec0ab8f203232a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:56:55 -0700 Subject: [PATCH 113/394] feat(native-chat): attach files to a structured chat on a paired server (#25146) * feat(native-chat): a paired server keeps a store for chat attachments A structured chat on a paired Orca server had nowhere to put a file the client attached. The server now keeps a per-chat attachment store under its userData, filled through agentSessionAttachment.uploadStart/Append/Commit/Abort and read back for previews through agentSessionAttachment.read, behind the structured session gate and advertised as agent-session.attachments.v1. Nothing records cleanup as owed: a sweep re-derives what may go from the host's chat records and journal (unfinished uploads after an hour, uploads for a chat that never existed or that its journal never mentions after a day). The clipboard RPC's in-flight bookkeeping moves into a shared ChunkedUploadRegistry both use. * feat(native-chat): upload attached files into a paired server's chat store Main streams dropped or picked files (fs:uploadPathsToAgentSessionAttachments) and pasted images (clipboard:saveImageAsTempFile with agentSessionAttachment) into the chat's store on its paired server, reusing the file-import slice streamer and pinning every call to the pairing revision and server process. The browser client's paste takes the same route. * feat(native-chat): attach files to a structured chat on a paired server Pasted images, files dropped from Finder/Explorer and the file picker no longer refuse with "Local attachments are not available for remote sessions." in a structured chat on a paired server: they upload into that server's chat store and the chat gets only the stored path. Dropped files show as pending chips at once so Send waits for them; every attached item carries the server, pairing and chat it was stored for, and a send to anywhere else drops it with the existing "changed hosts" notice. Chips and transcript images read stored files back through the server, never from this machine's disk. An older server gets an update notice instead of an upload. The terminal-backed chat keeps refusing. * test(native-chat): type the attachment test doubles without bare casts * refactor(native-chat): keep the clipboard RPC on its own upload bookkeeping The chunked upload registry stays for the chat attachment store only, beside it. * feat(native-chat): claim chat attachments when the host admits a message A message's references into the host's attachment store are claimed in the same journal transaction that makes it durable (a direct send, a queued draft, a /clear carry). The sweep deletes an upload only after marking it in that database while no claim exists, so a send and a sweep can never both win: a client's send that names an expired or foreign upload is refused whole with the new attachmentExpired reason, before anything is recorded. This replaces scanning chat transcripts for paths. The store is now //, refuses uploads for chats the host does not hold, caps stored names in UTF-8 bytes and avoids Windows device names. The preview read goes through the protected bounded read and the request's reply budget, so an image too large for one reply is refused instead of closing the connection. * feat(native-chat): let structured Claude read the host's chat attachment store Attached non-image files live outside the workspace; the store root is passed as an added directory so the agent reads them without asking, the common pattern. * fix(native-chat): skip a dropped folder before staging walks into it * fix(native-chat): pin the browser client's chat paste to its server Every upload call goes to the environment the paste was meant for and checks its pairing and server process, as the desktop upload does; a re-pair ends the upload instead of storing the image on another server. * refactor(native-chat): let the host's claim be the only attachment check The composer no longer records which server and pairing each attachment came from, and Send no longer strips attachments it judges foreign: the host refuses an expired or unknown stored path when it admits the message, which also covers retries, Stop-restores and queued edits the client check missed. Previews of stored files route through the chat's own server by path. Removing a file's chip while it uploads now keeps its @path out of the draft. A dropped or picked file shows its name and kind on its chip while it uploads, with no separate progress toast, and files that did not attach are named in one notice. A rich-text paste into a chat on an older server no longer shows the update notice beside the pasted text. * chore(native-chat): keep the claim hook beside the draft consume and fit the line budgets The submission's claim runs in the same append hook as a draft's consume, owned by the queued-message collaborator, so the journal store stays within its size limit. Formats the new files and regenerates the runtime-required English catalog. * test(native-chat): pin the attachment store grant in the Claude launch, restore upload result types * fix(native-chat): create the attachment store at install and claim only exact store paths Claude drops an added directory that does not exist when it starts, so the store root is created when the host installs it, best effort. A commit keeps its upload in flight until the rename lands, so a sweep never takes a slow upload's part file. Only a path under this host's exact store root is claimed and required; any other mention of a store path is plain text and never refuses the message. * refactor(native-chat): reuse the newer-Orca notice and drop leftover attach options An older server's refusal to store attachments uses the notice every other write it refuses already shows, so one string fewer in every catalog; the attach callbacks lose an options parameter no caller passes since the provenance check went. * fix(native-chat): give a message refused for an expired attachment back to the composer Sending the same message again can never bring the attachment back, so instead of a Retry that always fails the message's text and images return to the composer, as a Stop's withdrawn message does, and the notice says what to remove. * fix(native-chat): keep uploads across a prompt, say why a file did not attach An upload that finishes while a prompt card has replaced the composer lands in the scope's attachment and draft caches, which the composer reads back when it returns, instead of the unmounted composer. The single failure notice names the cause the files share, such as the size limit. Rich text pasted into a chat on an older server no longer flashes an image chip: the chip waits for the server to take the image. A picked command waits, as Send does, while an attachment is still uploading. * fix(native-chat): keep a paste's upload across a prompt; pin the Claude grant hand-off A paste uploading into a paired server's store keeps its chip live when a prompt card unmounts the composer, and its result is filed for the composer's return, as a drop's is. A failure cause ending in full-width punctuation gets no extra full stop. A runtime test pins that the store root reaches Claude's launch resolver through the adapter. * test(native-chat): type the grant hand-off captures without casts * test(native-chat): pin that the composer files a paste's upload across a prompt * fix(native-chat): retry a held rename at commit and keep cut names Windows-safe A commit renames the part file with the Windows retry the app uses elsewhere, so an antivirus or indexer holding it briefly no longer loses the upload. A name cut to the byte limit is stripped of trailing dots and spaces again. The upload pump's result cast states why it holds. * fix(native-chat): keep attachments still on their way in the pane's attachment cache A prompt card unmounts the composer, and an attachment still saving or uploading lived only in that composer: one that came back showed no pending chip, so Send went out without the file and the file then landed in the next draft. Pending chips now live in the pane's attachment cache beside settled ones and settle there whichever composer is showing, so Send waits for them after a remount too. A stored file's @path goes into the pane's draft cache, which keeps it through an input-method composition and a remount. A rich-text paste's image is registered as a hidden pending chip before the server is asked, so Send waits for it from the start, and it shows only once the server takes it. * test(native-chat): a dropped file still uploading stays pending across a remount * fix(native-chat): reveal a rich-text image in a composer that came back; keep caret inserts A rich-text paste's held image is revealed through the pane's attachment cache, so a composer a prompt card remounted during the server's answer shows it rather than holding Send on an invisible chip. A stored file's @path goes in at the caret while the composer is showing and not composing, as every other attach does; only mid-composition or after a remount does it go to the pane's draft. * fix(native-chat): never evict a pane's attachments while a composer shows them or one is on its way The pane attachment cache is now where chips live, so its 128-scope bound passes over a scope a mounted composer subscribes to or that still holds a pending attachment, and evicts the oldest scope nobody uses instead. Protected scopes are bounded by mounted composers and attachments in flight. * fix(native-chat): never evict the scope just written when every older one is in use * fix(native-chat): keep the attachment store out of the RPC method table's imports The method table is imported far and wide (the SSH relay's CLI included), and the attachment RPCs pulled in the store, whose preview read loads modules that read fs constants at load: any test that mocks fs/promises without them failed to import. The installed store now lives in a small registry module; only the host wiring loads the store itself. * refactor(native-chat): the submission hook gets its own module Merging main added the operation receipt to the queued-message insert, which put journal-queued-messages.ts over the 300-line limit. The submission hook only uses the queued-messages object's public methods, so it moves out. * refactor(native-chat): fit the attachment sweep and paste upload into main's line budgets After merging main, the record store and the clipboard handlers each ran a few lines over the 300-line budget. The sweep now asks the record store whether a chat is recorded through the two lookups it already has (readable or unreadable) instead of a new id-listing method, and the paired-server paste upload lives beside the other attachment uploads. No behavior changes. * fix(native-chat): pending chips sit beside main's saved draft, which keeps server-stored images Main now saves a composer's draft (text and settled images) in one store that survives a reload or quit. This branch kept every chip, settled or not, in its own pane cache. After the merge the saved draft owns settled images and the pane cache holds only chips still on their way: they come back pending in a composer a prompt card remounted, hold Send, settle into the saved draft whichever composer is showing, and are never saved themselves, so a restored draft cannot bring back an upload as if it were attached. An image a paired server stored for the chat is saved with the draft as the real image (a pasted one is no longer turned into a "Not kept" placeholder), and the restore check leaves it to that server, whose claim at Send refuses one it no longer holds, instead of asking this machine's disk for a path that only exists on the server. Also: previews and the pending subscription move into their own small hooks to fit the line budget, the "local attachments" notice moves to the composer-target module so the attachments hook no longer pulls in the upload module's imports, and tests follow main's new mocks. * test(native-chat): give the claims test main's provider handle and message source * fix(native-chat): a paste still uploading dies with its tab or workspace A pasted image still uploading to a paired server sits in the pending chip cache, which by design outlives the composer and its tab. Removing the workspace deleted its saved drafts but left those chips, so the upload finishing afterwards recreated and saved the deleted draft. With the workspace's tabs gone it had no owner either, so no later workspace cleanup could ever remove it. A pending chip now records the owner its draft would have, resolved the way the draft store resolves one when the chip is added. Workspace removal and a user's tab close drop pending chips by the same owner, conversation and tab matches they delete drafts by, and a chip that settles after its chat's tab closed writes its draft under the owner it was begun in. * fix(native-chat): the claim type uses the journal's own SQLite type * refactor(native-chat): read the pending-image handlers off the composer's attachments Main's multi-file picker (#23956) and this branch together took NativeChatComposer over the 400-line limit; reading the two pending-image handlers the way the neighbouring ones already are keeps it at the limit. * test(native-chat): pass the launch-args resolver main now requires in the attachment grant test * fix(native-chat): grant the attachment store beside folders the saved Claude Arguments add Main (#25721) now builds additionalDirectories from the user's --add-dir arguments; this branch's attachment-store grant replaced that list instead of adding to it. Both are granted now. Moves the Claude session-id derivation to its own module to keep the resolver under the line limit. * test(native-chat): run the attachment store's SQLite tests in the Node runtime project * test(native-chat): follow main's attach ownership recheck (#25749) in the attachment tests * test(native-chat): a named upload chip still shows when the agent takes no images * fix(native-chat): a paired-server image paste failure keeps its error apart, as main's notice card does * fix(native-chat): the attachment sweep reads the chat list directly now that records have no import still owed --- .../scripts/vitest-sqlite-runtime-files.mjs | 4 + ...laude-structured-launch-resolution.test.ts | 38 ++- .../claude-structured-launch-resolution.ts | 21 +- .../claude/claude-structured-session-id.ts | 10 + .../agent-session-attachment-upload-ipc.ts | 36 +++ .../agent-session-attachment-upload.test.ts | 166 ++++++++++ .../ipc/agent-session-attachment-upload.ts | 216 +++++++++++++ .../filesystem/filesystem-write-handlers.ts | 2 + src/main/ipc/runtime-upload-file-stream.ts | 40 ++- .../agent-session-attachment-claims.test.ts | 280 ++++++++++++++++ .../agent-session-attachment-claims.ts | 201 ++++++++++++ .../agent-session-attachment-references.ts | 94 ++++++ ...agent-session-attachment-store-registry.ts | 15 + .../agent-session-attachment-store.test.ts | 231 +++++++++++++ .../agent-session-attachment-store.ts | 225 +++++++++++++ .../agent-session-attachment-sweep.test.ts | 199 ++++++++++++ .../agent-session-attachment-sweep.ts | 157 +++++++++ .../chunked-upload-registry.ts | 130 ++++++++ .../agent-session-journal/journal-database.ts | 2 + .../journal-host-database.ts | 11 +- .../journal-queued-messages.ts | 24 +- .../journal-store-collaborators.ts | 17 +- .../journal-submission-hook.ts | 30 ++ .../journal-submission-writer.ts | 8 +- ...agent-session-attachment-admission.test.ts | 145 +++++++++ ...tructured-agent-session-queued-messages.ts | 33 +- .../structured-agent-session-queued-send.ts | 1 + .../structured-agent-session-turns.ts | 15 + src/main/runtime/rpc/methods/index.ts | 2 + ...ructured-agent-session-attachments.test.ts | 216 +++++++++++++ .../structured-agent-session-attachments.ts | 76 +++++ .../structured-agent-runtime-registrations.ts | 2 + ...ed-agent-session-attachment-wiring.test.ts | 67 ++++ ...uctured-agent-session-attachment-wiring.ts | 68 ++++ .../structured-agent-session-runtime.ts | 11 + ...structured-claude-attachment-grant.test.ts | 82 +++++ .../structured-claude-runtime-adapter.ts | 3 + ...ard-agent-session-attachment-paste.test.ts | 120 +++++++ src/main/window/clipboard-image-temp-file.ts | 3 + src/main/window/clipboard-ipc-handlers.ts | 10 + src/preload/api/filesystem-api.ts | 8 + src/preload/api/fs-bridge.ts | 8 + ...ui-bridge-clipboard-and-window-controls.ts | 2 + src/preload/api/ui-window-api.ts | 2 + .../NativeChatComposer.paste-remount.test.tsx | 256 +++++++++++++++ .../native-chat/NativeChatComposer.test.tsx | 3 +- .../native-chat/NativeChatComposer.tsx | 16 +- .../native-chat/NativeChatComposerField.tsx | 13 +- .../NativeChatImageAttachmentPreview.test.tsx | 65 +++- .../NativeChatImageAttachmentPreview.tsx | 69 +++- .../agent-session-write-notice-text.ts | 4 + .../native-chat-attachment-upload.test.ts | 88 ++++- .../native-chat-attachment-upload.ts | 123 ++++++- .../native-chat-clipboard-image-save.ts | 89 +++++ .../native-chat-composer-draft-image-check.ts | 9 +- ...ive-chat-composer-draft-lifecycle.test.tsx | 32 +- .../native-chat-composer-draft-persistence.ts | 9 +- .../native-chat-composer-draft-store.test.ts | 11 + .../native-chat-composer-draft-store.ts | 21 +- .../native-chat-composer-drop-scope.test.tsx | 1 + .../native-chat-composer-target.ts | 7 + .../native-chat/native-chat-draft-cache.ts | 2 + ...-external-attachments-destination.test.tsx | 1 + ...tive-chat-pending-attachment-cache.test.ts | 131 ++++++++ .../native-chat-pending-attachment-cache.ts | 169 ++++++++++ .../native-chat-session-attachment-drop.ts | 122 +++++++ ...session-withdrawn-message-restore.test.tsx | 44 +++ ...agent-session-withdrawn-message-restore.ts | 47 ++- ...ative-chat-composer-attachment-previews.ts | 77 +++++ ...-native-chat-composer-attachments.test.tsx | 131 +++++++- .../use-native-chat-composer-attachments.ts | 201 ++++++------ ...-native-chat-composer-image-input.test.tsx | 25 ++ ...omposer-paste-session-attachments.test.tsx | 303 ++++++++++++++++++ .../use-native-chat-composer-paste.ts | 145 ++++----- ...-native-chat-external-attachments.test.tsx | 253 ++++++++++++++- .../use-native-chat-external-attachments.ts | 73 ++++- .../use-native-chat-paste-lifetime.ts | 128 ++++++++ ...ive-chat-structured-composer-send.test.tsx | 16 + ...se-native-chat-structured-composer-send.ts | 3 +- .../use-structured-agent-session-outbox.ts | 5 +- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 5 + src/renderer/src/i18n/locales/es.json | 5 + src/renderer/src/i18n/locales/fr.json | 5 + src/renderer/src/i18n/locales/ja.json | 5 + src/renderer/src/i18n/locales/ko.json | 5 + src/renderer/src/i18n/locales/zh.json | 5 + .../src/runtime/runtime-file-client.test.ts | 29 ++ .../src/runtime/runtime-file-read-client.ts | 16 + .../native-chat-launch-draft-teardown.test.ts | 21 ++ .../workspace-chat-draft-removal.test.ts | 188 +++++++++-- .../teardown/removed-worktree-chat-drafts.ts | 11 +- .../src/store/terminals/terminal-tab-close.ts | 2 + ...eb-agent-session-attachment-upload.test.ts | 81 +++++ .../web-agent-session-attachment-upload.ts | 89 +++++ .../src/web/preload-api/web-filesystem-api.ts | 1 + .../src/web/preload-api/web-ui-api.ts | 12 + src/shared/agent-session-attachments.test.ts | 35 ++ src/shared/agent-session-attachments.ts | 114 +++++++ src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/agent-session-write-notice-copy.ts | 2 + src/shared/protocol-version.ts | 5 + .../agent-session-attachment-params.ts | 36 +++ .../rpc-params-catalog.generated.ts | 11 + 105 files changed, 6087 insertions(+), 324 deletions(-) create mode 100644 src/main/claude/claude-structured-session-id.ts create mode 100644 src/main/ipc/agent-session-attachment-upload-ipc.ts create mode 100644 src/main/ipc/agent-session-attachment-upload.test.ts create mode 100644 src/main/ipc/agent-session-attachment-upload.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts create mode 100644 src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts create mode 100644 src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-hook.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-attachments.ts create mode 100644 src/main/runtime/structured-agent-session-attachment-wiring.test.ts create mode 100644 src/main/runtime/structured-agent-session-attachment-wiring.ts create mode 100644 src/main/runtime/structured-claude-attachment-grant.test.ts create mode 100644 src/main/window/clipboard-agent-session-attachment-paste.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts create mode 100644 src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts create mode 100644 src/shared/agent-session-attachments.test.ts create mode 100644 src/shared/agent-session-attachments.ts create mode 100644 src/shared/rpc-contract/agent-session-attachment-params.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 0edefd1c8e2e..fc93f232acbb 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -35,6 +35,8 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/ipc/ssh-host-partition-session-export.test.ts', 'src/main/ipc/ssh-host-server-on-connect-wiring.test.ts', 'src/main/ipc/ssh-managed-server-move-conversion.test.ts', + 'src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts', + 'src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-conversation-window.test.ts', 'src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts', @@ -46,6 +48,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-claude-hung-start-stop.test.ts', @@ -187,6 +190,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', + 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', 'src/main/ssh-expired-lease-pane-readoption.test.ts', 'src/main/ssh-reattach-pane-cardinality.test.ts', diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 5427756baf2c..d0baf0e8b925 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -63,7 +63,8 @@ function resolverFor( // Manual by default so a test that is not about permissions is not silently about them. agentDefaultArgs: Record = { claude: '' }, hasTranscript: () => Promise = async () => true, - resolveLaunchArgs?: () => string[] + resolveLaunchArgs?: () => string[], + attachmentDirectory?: string ) { return createClaudeStructuredLaunchResolver({ store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, @@ -73,7 +74,8 @@ function resolverFor( resolvePermissionMode: () => claudeStructuredPermissionModeForSettings({ agentDefaultArgs }), hasTranscript, resolveLaunchArgs: resolveLaunchArgs ?? (() => value?.launchArgs ?? []), - ...(resolveEnv ? { resolveEnv } : {}) + ...(resolveEnv ? { resolveEnv } : {}), + ...(attachmentDirectory ? { attachmentDirectory } : {}) }) } @@ -424,6 +426,38 @@ describe('claude structured launch resolution', () => { } }) + it("lets the agent read the host's chat attachment store, outside the workspace", async () => { + const launch = await resolverFor( + record(), + undefined, + false, + { claude: '' }, + async () => true, + undefined, + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual(['/state/agent-session-attachments']) + expect(launch.cwd).toBe('/repos/workspace-1') + }) + + it('grants the attachment store beside folders the saved Arguments add', async () => { + const launch = await resolverFor( + record(), + undefined, + false, + { claude: '' }, + async () => true, + () => ['--add-dir', '/extra'], + '/state/agent-session-attachments' + )({ identity: IDENTITY }) + + expect(launch.options.additionalDirectories).toEqual([ + '/extra', + '/state/agent-session-attachments' + ]) + }) + it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ resolveLaunchArgs: () => [], diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index de64c7e648d1..f916e284d877 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -1,5 +1,4 @@ import { getClaudeProfileRouter } from '../claude-accounts/claude-profile-installed-router' -import { createHash } from 'node:crypto' import type { Options as ClaudeAgentSdkOptions, PermissionMode @@ -44,6 +43,9 @@ import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-sup import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' +import { claudeSessionIdForOrcaSession } from './claude-structured-session-id' + +export { claudeSessionIdForOrcaSession } export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const export const CLAUDE_SESSION_STATE_EVENTS_ENV = 'CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS' @@ -135,6 +137,9 @@ export type ClaudeStructuredLaunchResolverDeps = { authSwitchSettleTimeoutMs?: number /** Account state for the managed-account gate; null when it cannot be read, which refuses. */ readManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** The host's chat attachment store: files a client attached live there, outside the workspace, + * and the agent reads them without asking. */ + attachmentDirectory?: string /** 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. */ @@ -198,14 +203,6 @@ export async function assertClaudeAuthSwitchSettled( } } -export function claudeSessionIdForOrcaSession(sessionId: string): string { - const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) - bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 - bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 - const hex = bytes.toString('hex') - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - export function createClaudeStructuredLaunchResolver( deps: ClaudeStructuredLaunchResolverDeps ): (input: { identity: AgentSessionJournalIdentity }) => Promise { @@ -265,7 +262,11 @@ export function createClaudeStructuredLaunchResolver( env: claudeProbeEnv(sources) }) const configured = claudeStructuredLaunchArgs(await deps.resolveLaunchArgs()) - const { additionalDirectories } = configured + // The user's own --add-dir folders, plus the host's chat attachment store. + const additionalDirectories = [ + ...configured.additionalDirectories, + ...(deps.attachmentDirectory ? [deps.attachmentDirectory] : []) + ] const permission = claudeStructuredPermissionOptions( (await deps.resolvePermissionMode?.()) ?? 'default' ) diff --git a/src/main/claude/claude-structured-session-id.ts b/src/main/claude/claude-structured-session-id.ts new file mode 100644 index 000000000000..abdda0721f8e --- /dev/null +++ b/src/main/claude/claude-structured-session-id.ts @@ -0,0 +1,10 @@ +import { createHash } from 'node:crypto' + +/** A stable UUID (v4 layout) for the Claude conversation behind an Orca chat. */ +export function claudeSessionIdForOrcaSession(sessionId: string): string { + const bytes = createHash('sha256').update(`orca-claude:${sessionId}`).digest().subarray(0, 16) + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40 + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/src/main/ipc/agent-session-attachment-upload-ipc.ts b/src/main/ipc/agent-session-attachment-upload-ipc.ts new file mode 100644 index 000000000000..9fcf1ba357d5 --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload-ipc.ts @@ -0,0 +1,36 @@ +import { app, ipcMain } from 'electron' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { uploadExternalPathsToAgentSessionAttachments } from './agent-session-attachment-upload' +import { abortWhenRendererGone } from './renderer-lifetime-abort' + +export function registerAgentSessionAttachmentUploadHandlers(): void { + ipcMain.handle( + 'fs:uploadPathsToAgentSessionAttachments', + async ( + event, + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ): Promise => { + const userDataPath = app.getPath('userData') + // Why: a reload or close must stop the transfer; the server sweeps the part file it leaves. + const lifetime = abortWhenRendererGone(event.sender) + try { + return await uploadExternalPathsToAgentSessionAttachments( + { + ...args, + // The manual-disconnect check keys on the resolved id, not whatever selector came in. + environmentId: resolveEnvironment(userDataPath, args.environmentId).id, + userDataPath, + signal: lifetime.signal + }, + args.paths + ) + } finally { + lifetime.dispose() + } + } + ) +} diff --git a/src/main/ipc/agent-session-attachment-upload.test.ts b/src/main/ipc/agent-session-attachment-upload.test.ts new file mode 100644 index 000000000000..80300d92674e --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.test.ts @@ -0,0 +1,166 @@ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' + +type CallOptions = { expectedEnvironmentRuntimeId?: string; signal?: AbortSignal } +type Call = { + method: string + params: Record + revision?: number + options?: CallOptions +} + +const calls: Call[] = [] +let serverStore: AgentSessionAttachmentStore +let failMethod: string | null = null + +// The paired server, played by a real store: every call answers as the RPC handler would. +vi.mock('./runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: async ( + _userDataPath: string, + _environmentId: string, + method: string, + params: Record, + _timeoutMs?: number, + revision?: number, + _envelope?: unknown, + options?: CallOptions + ) => { + calls.push({ method, params, revision, options }) + if (method === failMethod) { + return { ok: false, error: { code: 'internal', message: `${method} failed` } } + } + const callerKey = 'client-a' + const uploadId = String(params.uploadId) + try { + const result = + method === 'agentSessionAttachment.uploadStart' + ? await serverStore.startUpload({ + callerKey, + sessionId: String(params.sessionId), + name: String(params.name), + byteLength: Number(params.byteLength) + }) + : method === 'agentSessionAttachment.uploadAppend' + ? await serverStore.appendChunk({ + callerKey, + uploadId, + offset: Number(params.offset), + contentBase64: String(params.contentBase64) + }) + : method === 'agentSessionAttachment.uploadCommit' + ? await serverStore.commitUpload({ callerKey, uploadId }) + : await serverStore.abortUpload({ callerKey, uploadId }) + return { ok: true, result } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: { code: 'internal', message } } + } + } +})) + +const { uploadBufferToAgentSessionAttachments, uploadExternalPathsToAgentSessionAttachments } = + await import('./agent-session-attachment-upload') + +let workDir: string +const context = () => ({ + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a', + userDataPath: join(workDir, 'client-user-data') +}) + +beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'orca-attachment-upload-')) + serverStore = new AgentSessionAttachmentStore( + join(workDir, 'server', 'agent-session-attachments'), + { hasSession: () => true } + ) + calls.length = 0 + failMethod = null +}) + +afterEach(async () => { + serverStore.clearInFlightForTests() + await rm(workDir, { recursive: true, force: true }) +}) + +describe('uploadExternalPathsToAgentSessionAttachments', () => { + it('uploads dropped files in order and returns only server paths', async () => { + const big = Buffer.alloc(1024 * 1024, 7) + await writeFile(join(workDir, 'big.bin'), big) + await writeFile(join(workDir, 'notes.md'), '# hi') + + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'big.bin'), + join(workDir, 'notes.md') + ]) + + expect(result.skipped).toEqual([]) + expect(result.failed).toEqual([]) + expect(result.uploaded.map(({ sourcePath }) => sourcePath)).toEqual([ + join(workDir, 'big.bin'), + join(workDir, 'notes.md') + ]) + for (const { path } of result.uploaded) { + expect(path.startsWith(serverStore.rootDir)).toBe(true) + } + expect(await readFile(result.uploaded[0].path)).toEqual(big) + expect(await readFile(result.uploaded[1].path, 'utf8')).toBe('# hi') + }) + + it('pins every call to the pairing and every call to the server process', async () => { + await writeFile(join(workDir, 'a.txt'), 'a') + await uploadExternalPathsToAgentSessionAttachments(context(), [join(workDir, 'a.txt')]) + expect(calls.map((call) => call.method)).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + for (const call of calls) { + expect(call.revision).toBe(7) + expect(call.options?.expectedEnvironmentRuntimeId).toBe('runtime-a') + } + }) + + it('skips a folder as unsupported without walking into it', async () => { + await mkdir(join(workDir, 'folder', 'nested'), { recursive: true }) + await writeFile(join(workDir, 'folder', 'inside.txt'), 'x') + // A link inside would make the stager skip the folder as a symlink: never reached. + await symlink(join(workDir, 'folder', 'inside.txt'), join(workDir, 'folder', 'nested', 'link')) + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'folder') + ]) + expect(result.skipped).toEqual([{ sourcePath: join(workDir, 'folder'), reason: 'unsupported' }]) + expect(calls).toEqual([]) + }) + + it('aborts a failed upload on the server and reports the file as failed', async () => { + await writeFile(join(workDir, 'a.txt'), 'a') + failMethod = 'agentSessionAttachment.uploadCommit' + const result = await uploadExternalPathsToAgentSessionAttachments(context(), [ + join(workDir, 'a.txt') + ]) + expect(result.uploaded).toEqual([]) + expect(result.failed).toEqual([ + { sourcePath: join(workDir, 'a.txt'), reason: 'agentSessionAttachment.uploadCommit failed' } + ]) + expect(calls.at(-1)?.method).toBe('agentSessionAttachment.uploadAbort') + }) +}) + +describe('uploadBufferToAgentSessionAttachments', () => { + it('stores a pasted image under the chat', async () => { + const png = Buffer.alloc(900 * 1024, 3) + const stored = await uploadBufferToAgentSessionAttachments(context(), 'orca-paste-1.png', png) + expect(stored.name).toBe('orca-paste-1.png') + expect(await readFile(stored.path)).toEqual(png) + // 900 KiB in 384 KiB slices. + expect( + calls.filter((call) => call.method === 'agentSessionAttachment.uploadAppend') + ).toHaveLength(3) + }) +}) diff --git a/src/main/ipc/agent-session-attachment-upload.ts b/src/main/ipc/agent-session-attachment-upload.ts new file mode 100644 index 000000000000..258c5d355d9a --- /dev/null +++ b/src/main/ipc/agent-session-attachment-upload.ts @@ -0,0 +1,216 @@ +// Client side of chat attachments on a paired server: main owns the file handles, the clipboard and +// the runtime socket, so it pumps the bytes into the server's attachment store and hands the +// renderer only the server path. + +import { randomUUID } from 'node:crypto' +import { lstat } from 'node:fs/promises' +import { basename } from 'node:path' +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES, + AGENT_SESSION_ATTACHMENT_MAX_BYTES, + agentSessionPastedImageName, + type AgentSessionAttachmentClipboardTarget, + type AgentSessionAttachmentPathUploadResult, + type AgentSessionAttachmentUploadCommitResult, + type AgentSessionAttachmentUploadStartResult, + type AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' +import { resolveEnvironment } from '../../shared/runtime-environment-store' +import { callRuntimeEnvironment } from './runtime-environment-transport-routing' +import { + isRuntimeEnvironmentManuallyDisconnected, + RUNTIME_MANUALLY_DISCONNECTED_MESSAGE +} from './runtime-environment-manual-disconnect' +import { stageOneSourceForRuntimeUpload } from './filesystem-runtime-upload-staging' +import { streamExternalFileSlices } from './runtime-upload-file-stream' +import { formatByteCeiling } from './runtime-import-limits' + +const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 + +async function isDirectory(path: string): Promise { + try { + return (await lstat(path)).isDirectory() + } catch { + // The stager reports a path it cannot read with its own reason. + return false + } +} + +type UploadContext = AgentSessionAttachmentUploadTarget & { + userDataPath: string + signal?: AbortSignal +} + +async function callAttachmentMethod( + context: UploadContext, + method: string, + params: unknown +): Promise { + if (isRuntimeEnvironmentManuallyDisconnected(context.environmentId)) { + throw new Error(RUNTIME_MANUALLY_DISCONNECTED_MESSAGE) + } + const response = await callRuntimeEnvironment( + context.userDataPath, + context.environmentId, + method, + params, + ATTACHMENT_CALL_TIMEOUT_MS, + context.expectedEnvironmentPairingRevision, + undefined, + { + expectedEnvironmentRuntimeId: context.expectedEnvironmentRuntimeId, + signal: context.signal + } + ) + if (response.ok !== true) { + throw new Error(response.error.message || response.error.code) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result type of the agentSessionAttachment.* method it calls, whose server handler returns exactly that shape. + return response.result as TResult +} + +/** start → body → commit; any failure aborts, so the server frees the slot and its part file. */ +async function uploadAttachment( + context: UploadContext, + file: { name: string; byteLength: number }, + writeBody: ( + appendSlice: (contentBase64: string, offset: number) => Promise + ) => Promise +): Promise { + const { uploadId } = await callAttachmentMethod( + context, + 'agentSessionAttachment.uploadStart', + { sessionId: context.sessionId, name: file.name, byteLength: file.byteLength } + ) + try { + await writeBody((contentBase64, offset) => + callAttachmentMethod(context, 'agentSessionAttachment.uploadAppend', { + uploadId, + offset, + contentBase64 + }) + ) + return await callAttachmentMethod( + context, + 'agentSessionAttachment.uploadCommit', + { uploadId } + ) + } catch (error) { + await callAttachmentMethod( + { ...context, signal: undefined }, + 'agentSessionAttachment.uploadAbort', + { uploadId } + ).catch(() => {}) + throw error + } +} + +export function uploadBufferToAgentSessionAttachments( + context: UploadContext, + name: string, + buffer: Buffer +): Promise { + return uploadAttachment(context, { name, byteLength: buffer.byteLength }, async (appendSlice) => { + if (buffer.byteLength === 0) { + await appendSlice('', 0) + return + } + for ( + let offset = 0; + offset < buffer.byteLength; + offset += AGENT_SESSION_ATTACHMENT_CHUNK_BYTES + ) { + context.signal?.throwIfAborted() + const slice = buffer.subarray(offset, offset + AGENT_SESSION_ATTACHMENT_CHUNK_BYTES) + await appendSlice(slice.toString('base64'), offset) + } + }) +} + +/** A pasted image for a structured chat on a paired server, stored in that server's store. */ +export async function uploadPastedImageToAgentSessionAttachments( + target: AgentSessionAttachmentClipboardTarget, + runtimeEnvironmentId: string, + userDataPath: string, + buffer: Buffer +): Promise { + const stored = await uploadBufferToAgentSessionAttachments( + { + ...target, + environmentId: resolveEnvironment(userDataPath, runtimeEnvironmentId).id, + userDataPath + }, + agentSessionPastedImageName(Date.now(), randomUUID()), + buffer + ) + return stored.path +} + +/** + * Upload dropped or picked client-local files, in input order. A folder is skipped as + * unsupported: a chat attachment is a file. + */ +export async function uploadExternalPathsToAgentSessionAttachments( + context: UploadContext, + paths: readonly string[] +): Promise { + const result: AgentSessionAttachmentPathUploadResult = { uploaded: [], skipped: [], failed: [] } + for (const sourcePath of paths) { + context.signal?.throwIfAborted() + // Before staging, which would walk and open everything inside a folder only to skip it. + if (await isDirectory(sourcePath)) { + result.skipped.push({ sourcePath, reason: 'unsupported' }) + continue + } + const staged = await stageOneSourceForRuntimeUpload(sourcePath) + if (staged.status === 'skipped') { + result.skipped.push({ sourcePath, reason: staged.reason }) + continue + } + if (staged.status === 'failed') { + result.failed.push({ sourcePath, reason: staged.reason }) + continue + } + const entry = staged.entries[0] + if (staged.kind !== 'file' || !entry || entry.kind !== 'file') { + result.skipped.push({ sourcePath, reason: 'unsupported' }) + continue + } + if (entry.byteLength > AGENT_SESSION_ATTACHMENT_MAX_BYTES) { + result.failed.push({ + sourcePath, + reason: + `'${basename(sourcePath)}' is ${formatByteCeiling(entry.byteLength)}, over the ` + + `${formatByteCeiling(AGENT_SESSION_ATTACHMENT_MAX_BYTES)} chat attachment limit` + }) + continue + } + try { + const stored = await uploadAttachment( + context, + { name: basename(sourcePath), byteLength: entry.byteLength }, + async (appendSlice) => { + await streamExternalFileSlices({ + sourceRootPath: sourcePath, + entryRelativePath: '', + expected: entry, + signal: context.signal, + maxFileBytes: AGENT_SESSION_ATTACHMENT_MAX_BYTES, + limitLabel: 'chat attachment limit', + writeSlice: appendSlice + }) + } + ) + result.uploaded.push({ sourcePath, path: stored.path }) + } catch (error) { + if (context.signal?.aborted) { + throw error + } + result.failed.push({ + sourcePath, + reason: error instanceof Error ? error.message : String(error) + }) + } + } + return result +} diff --git a/src/main/ipc/filesystem/filesystem-write-handlers.ts b/src/main/ipc/filesystem/filesystem-write-handlers.ts index de02a93e7ac2..6aa3502adb78 100644 --- a/src/main/ipc/filesystem/filesystem-write-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-write-handlers.ts @@ -11,6 +11,7 @@ import { } from '../local-file-access-resolution' import { isENOENT } from '../filesystem-path-containment' import { registerFilesystemMutationHandlers } from '../filesystem-mutations' +import { registerAgentSessionAttachmentUploadHandlers } from '../agent-session-attachment-upload-ipc' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { assertLocalWriteTargetIsRegularFile } from './local-regular-file-read' @@ -99,4 +100,5 @@ export function registerFilesystemWriteHandlers(context: FilesystemHandlerContex ) registerFilesystemMutationHandlers(store) + registerAgentSessionAttachmentUploadHandlers() } diff --git a/src/main/ipc/runtime-upload-file-stream.ts b/src/main/ipc/runtime-upload-file-stream.ts index 079549e47111..00a5788537e5 100644 --- a/src/main/ipc/runtime-upload-file-stream.ts +++ b/src/main/ipc/runtime-upload-file-stream.ts @@ -35,6 +35,38 @@ export type RuntimeUploadFileStreamArgs = RuntimeUploadFileStreamRequest & { */ export async function streamExternalFileToRuntime( args: RuntimeUploadFileStreamArgs +): Promise<{ byteLength: number }> { + return streamExternalFileSlices({ + sourceRootPath: args.sourceRootPath, + entryRelativePath: args.entryRelativePath, + expected: args.expected, + signal: args.signal, + maxFileBytes: REMOTE_IMPORT_MAX_FILE_BYTES, + limitLabel: 'per-file remote import limit', + writeSlice: (contentBase64, offset) => sendChunk(args, contentBase64, offset > 0) + }) +} + +export type ExternalFileSliceStreamArgs = { + /** Client-local path of the dropped source (file, or root of a dropped directory). */ + sourceRootPath: string + /** Path of this file within the dropped directory; empty when the source is a file. */ + entryRelativePath: string + expected: StagedRuntimeUploadFileIdentity + signal?: AbortSignal + maxFileBytes: number + /** Names the ceiling in the refusal, e.g. "per-file remote import limit". */ + limitLabel: string + /** Sends one base64 slice; `offset` is in source bytes. An empty file sends one empty slice. */ + writeSlice: (contentBase64: string, offset: number) => Promise +} + +/** + * Read a staged client-local file in slices and hand each to `writeSlice`, refusing a source that + * was swapped or changed since staging. The destination decides what a slice becomes. + */ +export async function streamExternalFileSlices( + args: ExternalFileSliceStreamArgs ): Promise<{ byteLength: number }> { const sourcePath = resolveEntrySourcePath(args.sourceRootPath, args.entryRelativePath) @@ -72,16 +104,16 @@ export async function streamExternalFileToRuntime( const totalBytes = openedStat.size // Why: enforced again where the bytes actually move. Staging is a separate // call, so the ceiling only holds here if this boundary checks it too. - if (totalBytes > REMOTE_IMPORT_MAX_FILE_BYTES) { + if (totalBytes > args.maxFileBytes) { throw new Error( `'${displayPath}' is ${formatByteCeiling(totalBytes)}, over the ` + - `${formatByteCeiling(REMOTE_IMPORT_MAX_FILE_BYTES)} per-file remote import limit` + `${formatByteCeiling(args.maxFileBytes)} ${args.limitLabel}` ) } if (totalBytes === 0) { // Why: a zero-byte source produces no slices, but the destination still // has to exist before commitUpload renames it into place. - await sendChunk(args, '', false) + await args.writeSlice('', 0) } else { const buffer = Buffer.allocUnsafe(Math.min(RUNTIME_UPLOAD_SLICE_BYTES, totalBytes)) let offset = 0 @@ -93,7 +125,7 @@ export async function streamExternalFileToRuntime( if (bytesRead === 0) { throw new Error(`File truncated during upload: '${displayPath}'`) } - await sendChunk(args, buffer.subarray(0, bytesRead).toString('base64'), offset > 0) + await args.writeSlice(buffer.subarray(0, bytesRead).toString('base64'), offset) offset += bytesRead } } diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts new file mode 100644 index 000000000000..9401bf7e9a4a --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -0,0 +1,280 @@ +import { mkdir, mkdtemp, rm, writeFile } 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, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { formatNativeChatFileReference } from '../../../shared/agent-image-paste' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + isAgentSessionAttachmentExpiredError, + markUnclaimedUploadForSweep +} from './agent-session-attachment-claims' +import { + agentSessionAttachmentReferences, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +const UPLOAD_A = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const UPLOAD_B = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' + +function message(...blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +describe('agentSessionAttachmentReferences', () => { + const root = '/srv/orca/agent-session-attachments' + + it('finds image paths and file references in text, however the reference is quoted', () => { + const quoted = formatNativeChatFileReference(`${root}/${UPLOAD_B}/my "notes" and 'more'.txt`) + const references = agentSessionAttachmentReferences( + root, + message( + { type: 'image-ref', path: `${root}/${UPLOAD_A}/shot.png` }, + { type: 'text', text: `read ${quoted} please` } + ), + 'linux' + ) + expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + it("leaves a mention of any other store path as text: another server's, a ~ form, a bare name", () => { + const mentions = [ + `@/home/other/agent-session-attachments/${UPLOAD_A}/a.txt`, + `~/Library/Application Support/Orca/agent-session-attachments/${UPLOAD_A}/x.png`, + `see agent-session-attachments/${UPLOAD_A}`, + // This host's root as the tail of a longer path is still another path. + `/mnt${root}/${UPLOAD_A}/a.txt` + ] + for (const text of mentions) { + expect( + agentSessionAttachmentReferences(root, message({ type: 'text', text }), 'linux') + ).toEqual(new Set()) + } + }) + + it('matches a Windows store with either separator and in any case', () => { + const windowsRoot = 'C:\\Users\\me\\AppData\\Roaming\\orca\\agent-session-attachments' + const references = agentSessionAttachmentReferences( + windowsRoot, + message( + { + type: 'image-ref', + path: `c:/users/ME/appdata/roaming/orca/agent-session-attachments/${UPLOAD_A}/s.png` + }, + { type: 'text', text: `@"${windowsRoot}\\${UPLOAD_B.toUpperCase()}\\a b.txt"` } + ), + 'win32' + ) + expect([...references].sort()).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) +}) + +describe('claims written with the message', () => { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-c', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + let stateDirectory: string + let storeRoot: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function open(sessionId = IDENTITY.sessionId): Promise { + return journals.open({ + identity: { ...IDENTITY, sessionId }, + stateDirectory, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function storeUpload(uploadId: string, name = 'shot.png'): Promise { + await mkdir(join(storeRoot, uploadId), { recursive: true }) + await writeFile(join(storeRoot, uploadId, name), 'bytes') + return join(storeRoot, uploadId, name) + } + + function claims(): { upload_id: string; session_id: string }[] { + return openTestJournalHostDatabase(stateDirectory) + .db.prepare( + 'SELECT upload_id, session_id FROM agent_session_attachment_claims ORDER BY session_id' + ) + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + 'session_id' in row && + typeof row.upload_id === 'string' && + typeof row.session_id === 'string' + ? [{ upload_id: row.upload_id, session_id: row.session_id }] + : [] + ) + } + + function clientSend(journal: AgentSessionJournal, id: string, body: AgentJournalMessageItem) { + return journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body, + fence: 0, + origin: 'client' + }) + } + + beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-claims-')) + storeRoot = agentSessionAttachmentStoreRoot(stateDirectory) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(stateDirectory, { recursive: true, force: true }) + }) + + it("claims a client message's stored uploads in the same write", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + expect(journal.submission('send-1')).toBeTruthy() + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) + }) + + it('refuses the whole client message, writing nothing, when an upload is gone', async () => { + const journal = await open() + const error = await clientSend( + journal, + 'send-1', + message( + { type: 'text', text: 'see the image' }, + { type: 'image-ref', path: join(storeRoot, UPLOAD_A, 'shot.png') } + ) + ).catch((caught: unknown) => caught) + expect(isAgentSessionAttachmentExpiredError(error)).toBe(true) + expect(journal.submission('send-1')).toBeUndefined() + expect(claims()).toEqual([]) + }) + + it('refuses an upload the sweep already marked, even while its file is still on disk', async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(true) + const error = await clientSend(journal, 'send-1', message({ type: 'image-ref', path })).catch( + (caught: unknown) => caught + ) + expect(isAgentSessionAttachmentExpiredError(error)).toBe(true) + }) + + it('a claim landing first keeps the sweep from marking the upload', async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(false) + }) + + it("never refuses the host's own message, and claims what it names", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A) + await journal.appendSubmission({ + clientMessageId: 'host-1', + payloadFingerprint: 'fp', + body: message( + { type: 'image-ref', path }, + { type: 'image-ref', path: join(storeRoot, UPLOAD_B, 'gone.png') } + ), + fence: 0, + origin: 'host' + }) + expect(journal.submission('host-1')).toBeTruthy() + expect( + claims() + .map((claim) => claim.upload_id) + .sort() + ).toEqual([UPLOAD_A, UPLOAD_B].sort()) + }) + + it("refuses a client's draft naming a gone upload, and the /clear carry claims for its new chat", async () => { + const journal = await open() + const missing = message({ type: 'text', text: `@${join(storeRoot, UPLOAD_B, 'a.txt')}` }) + const refused = await journal.queuedMessages + .insert({ + messageId: 'draft-0', + body: missing, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + .catch((caught: unknown) => caught) + expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) + expect(journal.queuedMessages.list()).toHaveLength(0) + + const path = await storeUpload(UPLOAD_A, 'notes.txt') + const body = message({ type: 'text', text: `read @${path}` }) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + const replacement = await open('session-d') + await replacement.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp-d', + hostInstance: 'p', + carriedFrom: IDENTITY.sessionId + }) + expect(claims()).toEqual([ + { upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }, + { upload_id: UPLOAD_A, session_id: 'session-d' } + ]) + }) + + it("does not refuse a draft's conversion: the draft claimed its uploads when it was written", async () => { + const journal = await open() + const path = await storeUpload(UPLOAD_A, 'notes.txt') + const body = message({ type: 'text', text: `read @${path}` }) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint: 'fp', + hostInstance: 'p', + requireAttachments: true + }) + await rm(join(storeRoot, UPLOAD_A), { recursive: true }) + await journal.appendSubmission( + { clientMessageId: 'handoff-1', payloadFingerprint: 'fp', body, fence: 0, origin: 'client' }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + expect(journal.submission('handoff-1')).toBeTruthy() + }) + + it('keeps its claims across a host restart', async () => { + let journal = await open() + const path = await storeUpload(UPLOAD_A) + await clientSend(journal, 'send-1', message({ type: 'image-ref', path })) + await journals.closeAll() + journal = await open() + expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) + expect( + markUnclaimedUploadForSweep(openTestJournalHostDatabase(stateDirectory).db, UPLOAD_A, 1) + ).toBe(false) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts new file mode 100644 index 000000000000..ac41b427e58b --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.ts @@ -0,0 +1,201 @@ +// Which chats hold each stored attachment. A message claims the uploads it references in the same +// host transaction that makes it durable, and the sweep may delete only an upload nobody claims: +// both are synchronous statements on the host's one journal connection, so exactly one wins. + +import { existsSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + agentSessionAttachmentReferences, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' + +const CLAIM_SAVEPOINT = 'agent_session_attachment_claims' + +/** + * Created at every writable open with no `user_version` bump, like the draft table + * (`ensureQueuedMessagesTable`): an older build ignores them and stays writable. + * Claims are keyed by upload and chat, so a /clear carry adds the replacement as a second holder + * and a chat's deletion frees an upload only when its last holder goes. + */ +export function ensureAgentSessionAttachmentClaimTables(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS agent_session_attachment_claims ( + upload_id TEXT NOT NULL, + session_id TEXT NOT NULL, + claimed_at INTEGER NOT NULL, + PRIMARY KEY (upload_id, session_id) +); +CREATE TABLE IF NOT EXISTS agent_session_attachment_sweeps ( + upload_id TEXT NOT NULL PRIMARY KEY, + started_at INTEGER NOT NULL +); +`) +} + +export class AgentSessionAttachmentExpiredError extends Error { + constructor() { + super('A referenced chat attachment is no longer stored on this host.') + this.name = 'AgentSessionAttachmentExpiredError' + } +} + +export function isAgentSessionAttachmentExpiredError( + error: unknown +): error is AgentSessionAttachmentExpiredError { + return error instanceof AgentSessionAttachmentExpiredError +} + +function uploadIsStored(root: string, uploadId: string): boolean { + try { + return readdirSync(join(root, uploadId)).some( + (entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE + ) + } catch { + return false + } +} + +function isBeingSwept(db: Database.Database, uploadId: string): boolean { + return ( + db + .prepare('SELECT 1 FROM agent_session_attachment_sweeps WHERE upload_id = ?') + .get(uploadId) !== undefined + ) +} + +function insertClaims( + db: Database.Database, + uploadIds: Iterable, + sessionId: string, + now: number +): void { + const insert = db.prepare( + 'INSERT OR IGNORE INTO agent_session_attachment_claims (upload_id, session_id, claimed_at) VALUES (?, ?, ?)' + ) + for (const uploadId of uploadIds) { + insert.run(uploadId, sessionId, now) + } +} + +export type AgentSessionAttachmentClaim = { + stateDirectory: string + sessionId: string + body: AgentJournalMessageItem + /** A client's own new message: every upload of this host's store it names must still be stored, + * or the whole message is refused. The host's own writes (a /clear carry, a draft it sends) never refuse. */ + required: boolean + now: number +} + +/** + * Runs inside the transaction that writes the message. Required: throws + * `AgentSessionAttachmentExpiredError`, rolling the write back, when a reference names an upload + * this host does not hold or is deleting. Best effort: claims what it can and reports, never + * failing the write it rides on. + */ +export function claimAgentSessionAttachmentsInTransaction( + db: Database.Database, + claim: AgentSessionAttachmentClaim +): void { + const root = agentSessionAttachmentStoreRoot(claim.stateDirectory) + const uploadIds = agentSessionAttachmentReferences(root, claim.body) + if (uploadIds.size === 0) { + return + } + if (claim.required) { + if ( + [...uploadIds].some( + (uploadId) => isBeingSwept(db, uploadId) || !uploadIsStored(root, uploadId) + ) + ) { + throw new AgentSessionAttachmentExpiredError() + } + insertClaims(db, uploadIds, claim.sessionId, claim.now) + return + } + db.exec(`SAVEPOINT ${CLAIM_SAVEPOINT}`) + try { + insertClaims(db, uploadIds, claim.sessionId, claim.now) + db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) + } catch (error) { + db.exec(`ROLLBACK TO ${CLAIM_SAVEPOINT}`) + db.exec(`RELEASE ${CLAIM_SAVEPOINT}`) + console.warn('[agent-session-attachments] claim skipped:', { + sessionId: claim.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + } +} + +/** The sweep's side of the race: marks an upload as being deleted only while nobody claims it. + * True means this sweep won and may delete it; every later claim refuses it. */ +export function markUnclaimedUploadForSweep( + db: Database.Database, + uploadId: string, + now: number +): boolean { + const result = db + .prepare( + `INSERT OR IGNORE INTO agent_session_attachment_sweeps (upload_id, started_at) + SELECT ?, ? WHERE NOT EXISTS + (SELECT 1 FROM agent_session_attachment_claims WHERE upload_id = ?)` + ) + .run(uploadId, now, uploadId) + return Number(result.changes) > 0 +} + +export function listUploadsBeingSwept(db: Database.Database): string[] { + return db + .prepare('SELECT upload_id FROM agent_session_attachment_sweeps') + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + typeof row.upload_id === 'string' + ? [row.upload_id] + : [] + ) +} + +export function finishUploadSweep(db: Database.Database, uploadId: string): void { + db.prepare('DELETE FROM agent_session_attachment_sweeps WHERE upload_id = ?').run(uploadId) +} + +/** + * Claims nothing can use any more: their chat has no record, or their upload is gone from disk + * (removed outside Orca). Re-derived on every sweep, so a claim cannot outlive what it protects. + * Synchronous with the facts it reads, so a claim written meanwhile is judged on today's facts. + */ +export function pruneAgentSessionAttachmentClaims( + db: Database.Database, + facts: { root: string; recordedSessionIds: Pick, 'has'> | null } +): number { + const rows = db + .prepare('SELECT upload_id, session_id FROM agent_session_attachment_claims') + .all() + .flatMap((row) => + typeof row === 'object' && + row !== null && + 'upload_id' in row && + 'session_id' in row && + typeof row.upload_id === 'string' && + typeof row.session_id === 'string' + ? [{ uploadId: row.upload_id, sessionId: row.session_id }] + : [] + ) + const remove = db.prepare( + 'DELETE FROM agent_session_attachment_claims WHERE upload_id = ? AND session_id = ?' + ) + let removed = 0 + for (const { uploadId, sessionId } of rows) { + const chatGone = facts.recordedSessionIds !== null && !facts.recordedSessionIds.has(sessionId) + if (chatGone || !existsSync(join(facts.root, uploadId))) { + removed += Number(remove.run(uploadId, sessionId).changes) + } + } + return removed +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts new file mode 100644 index 000000000000..f08896ad4a5d --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-references.ts @@ -0,0 +1,94 @@ +// How the host recognizes its own attachment store in what a client sends: a stored file is +// `//`, and only the upload id is needed to know which upload it is. + +import { isAbsolute, join, relative, sep } from 'node:path' +import { AGENT_SESSION_ATTACHMENTS_DIR_NAME } from '../../../shared/agent-session-attachments' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' + +export const AGENT_SESSION_ATTACHMENT_PART_FILE = '.upload.part' + +const UPLOAD_ID_SOURCE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const UPLOAD_ID = new RegExp(`^${UPLOAD_ID_SOURCE}$`) + +export function agentSessionAttachmentStoreRoot(stateDirectory: string): string { + return join(stateDirectory, AGENT_SESSION_ATTACHMENTS_DIR_NAME) +} + +export function isAgentSessionAttachmentUploadId(value: string): boolean { + return UPLOAD_ID.test(value) +} + +/** The upload a stored file belongs to, or null for anything that is not exactly a committed file + * directly in an upload directory of this store. */ +export function parseAgentSessionAttachmentStorePath( + root: string, + filePath: string +): { uploadId: string; name: string } | null { + if (!isAbsolute(filePath)) { + return null + } + const inside = relative(root, filePath) + const segments = inside.split(sep) + const [uploadId, name] = segments + if ( + inside === '' || + isAbsolute(inside) || + segments.length !== 2 || + !uploadId || + !name || + !isAgentSessionAttachmentUploadId(uploadId) || + name === AGENT_SESSION_ATTACHMENT_PART_FILE + ) { + return null + } + return { uploadId, name } +} + +// A root preceded by one of these is the tail of some other, longer path. +const PATH_CHARACTER = /[\w.~\\/-]/ + +function normalizedRoot(root: string, platform: NodeJS.Platform): string { + const forward = root.replace(/\\/g, '/') + return platform === 'win32' ? forward.toLowerCase() : forward +} + +/** + * The uploads of this host's store a message body names, in image paths and file references in its + * text alike. Only a path that starts with this host's exact store root counts: matched on the + * decoded body by `/`, so quoting or a file name with spaces cannot hide one, + * and Windows paths match with either separator and in any case. Any other mention of a store + * path (another server's, a `~` form, a bare name) is only text. + */ +export function agentSessionAttachmentReferences( + root: string, + body: AgentJournalMessageItem, + platform: NodeJS.Platform = process.platform +): Set { + const uploadIds = new Set() + const ownRoot = normalizedRoot(root, platform) + const pattern = new RegExp( + `${AGENT_SESSION_ATTACHMENTS_DIR_NAME}[\\\\/](${UPLOAD_ID_SOURCE})(?![0-9a-z-])`, + platform === 'win32' ? 'gi' : 'g' + ) + for (const block of body.blocks) { + const text = + block.type === 'text' ? block.text : block.type === 'image-ref' ? block.path : undefined + if (!text) { + continue + } + for (const match of text.matchAll(pattern)) { + const rootEnd = match.index + AGENT_SESSION_ATTACHMENTS_DIR_NAME.length + const rootStart = rootEnd - root.length + const uploadId = match[1]?.toLowerCase() + if ( + uploadId && + rootStart >= 0 && + !PATH_CHARACTER.test(text.charAt(rootStart - 1)) && + normalizedRoot(text.slice(rootStart, rootEnd), platform) === ownRoot + ) { + uploadIds.add(uploadId) + } + } + } + return uploadIds +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts new file mode 100644 index 000000000000..e0f4201618a3 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store-registry.ts @@ -0,0 +1,15 @@ +// The installed attachment store, apart from the store itself: the RPC methods are imported +// wherever the method table is, and must not load the store's file-reading dependencies with it. + +import type { AgentSessionAttachmentStore } from './agent-session-attachment-store' + +let installedStore: AgentSessionAttachmentStore | null = null + +/** Installed with the structured host, which owns the state directory the store lives in. */ +export function setAgentSessionAttachmentStore(store: AgentSessionAttachmentStore | null): void { + installedStore = store +} + +export function getAgentSessionAttachmentStore(): AgentSessionAttachmentStore | null { + return installedStore +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts new file mode 100644 index 000000000000..fc0c57d30b24 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.test.ts @@ -0,0 +1,231 @@ +import { mkdtemp, readdir, readFile, rm, stat, symlink, writeFile, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionAttachmentStore } from './agent-session-attachment-store' +import { REMOTE_RPC_MAX_CONTENT_BYTES } from '../../../shared/remote-rpc-content-budget' +import { isMobileE2EETextPayloadWithinLimit } from '../../runtime/rpc/mobile-e2ee-outbound-admission' + +let root: string +let store: AgentSessionAttachmentStore +const caller = 'client-a' + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachments-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId.startsWith('session-') + }) +}) + +afterEach(async () => { + store.clearInFlightForTests() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) +}) + +async function upload(name: string, bytes: Buffer, sessionId = 'session-1') { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId, + name, + byteLength: bytes.byteLength + }) + await store.appendChunk({ + callerKey: caller, + uploadId, + offset: 0, + contentBase64: bytes.toString('base64') + }) + return store.commitUpload({ callerKey: caller, uploadId }) +} + +describe('AgentSessionAttachmentStore', () => { + it('stores the bytes in an upload directory of its own, keeping the file name', async () => { + const stored = await upload('screen shot.png', Buffer.from('png-bytes')) + expect(stored.name).toBe('screen shot.png') + expect(stored.byteLength).toBe(9) + expect(await readdir(store.rootDir)).toHaveLength(1) + expect(stored.path.startsWith(store.rootDir)).toBe(true) + expect(stored.path.endsWith('screen shot.png')).toBe(true) + expect(await readFile(stored.path, 'utf8')).toBe('png-bytes') + // The part file is gone once the name holds the whole file. + expect(await readdir(join(stored.path, '..'))).toEqual(['screen shot.png']) + }) + + it('keeps only the last path segment of a name, never a path out of the store', async () => { + const stored = await upload('../../../etc/passwd', Buffer.from('x')) + expect(stored.name).toBe('passwd') + expect(stored.path.startsWith(store.rootDir)).toBe(true) + }) + + it('refuses an upload for a chat this host does not hold', async () => { + await expect( + store.startUpload({ callerKey: caller, sessionId: 'unknown', name: 'a.txt', byteLength: 1 }) + ).rejects.toThrow('not on this host') + await expect(readdir(store.rootDir)).rejects.toThrow() + }) + + it('appends chunks in order and refuses one out of order or past the declared size', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 4 + }) + const chunk = (text: string) => Buffer.from(text).toString('base64') + await store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: chunk('ab') }) + await expect( + store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: chunk('cd') }) + ).rejects.toThrow('out of order') + await expect( + store.appendChunk({ callerKey: caller, uploadId, offset: 2, contentBase64: chunk('cde') }) + ).rejects.toThrow('exceeded its declared size') + await store.appendChunk({ callerKey: caller, uploadId, offset: 2, contentBase64: chunk('cd') }) + const stored = await store.commitUpload({ callerKey: caller, uploadId }) + expect(await readFile(stored.path, 'utf8')).toBe('abcd') + }) + + it('refuses an incomplete commit and removes what it had', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 10 + }) + await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('incomplete') + expect(await readdir(store.rootDir)).toEqual([]) + }) + + it('keeps an upload in flight until its file is renamed into place, so no sweep takes it', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await store.appendChunk({ callerKey: caller, uploadId, offset: 0, contentBase64: 'eA==' }) + const committing = store.commitUpload({ callerKey: caller, uploadId }) + expect(store.isUploadInFlight(uploadId)).toBe(true) + await committing + expect(store.isUploadInFlight(uploadId)).toBe(false) + }) + + it('stores an empty file', async () => { + const stored = await upload('empty.txt', Buffer.alloc(0)) + expect((await stat(stored.path)).size).toBe(0) + }) + + it('does not let another client touch an upload it did not start', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await expect( + store.appendChunk({ callerKey: 'client-b', uploadId, offset: 0, contentBase64: 'eA==' }) + ).rejects.toThrow('not found') + await expect(store.commitUpload({ callerKey: 'client-b', uploadId })).rejects.toThrow( + 'not found' + ) + await expect(store.abortUpload({ callerKey: 'client-b', uploadId })).rejects.toThrow( + 'not found' + ) + expect(store.isUploadInFlight(uploadId)).toBe(true) + }) + + it('removes an aborted upload', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + await store.abortUpload({ callerKey: caller, uploadId }) + expect(store.isUploadInFlight(uploadId)).toBe(false) + expect(await readdir(store.rootDir)).toEqual([]) + }) + + it('forgets and removes an upload its client abandoned', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'a.txt', + byteLength: 1 + }) + vi.advanceTimersByTime(5 * 60 * 1000 + 1) + vi.useRealTimers() + await vi.waitFor(async () => expect(await readdir(store.rootDir)).toEqual([])) + expect(store.isUploadInFlight(uploadId)).toBe(false) + await expect(store.commitUpload({ callerKey: caller, uploadId })).rejects.toThrow('not found') + }) + + it('reads back a stored image and nothing outside the store', async () => { + const stored = await upload('shot.png', Buffer.from('png-bytes')) + await expect(store.readPreview(stored.path)).resolves.toEqual({ + content: Buffer.from('png-bytes').toString('base64'), + isBinary: true, + isImage: true, + mimeType: 'image/png' + }) + const outside = join(root, 'outside.png') + await writeFile(outside, 'secret') + await expect(store.readPreview(outside)).rejects.toThrow('Not an attachment image') + const notImage = await upload('notes.txt', Buffer.from('text')) + await expect(store.readPreview(notImage.path)).rejects.toThrow('Not an attachment image') + }) + + it('reads only a file exactly at /', async () => { + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'shot.png', + byteLength: 3 + }) + const uploadDir = join(store.rootDir, uploadId) + // Deeper than a stored file: nothing the store wrote lives there. + await mkdir(join(uploadDir, 'nested'), { recursive: true }) + await writeFile(join(uploadDir, 'nested', 'x.png'), 'x') + await expect(store.readPreview(join(uploadDir, 'nested', 'x.png'))).rejects.toThrow( + 'Not an attachment image' + ) + }) + + it('never follows a link out of the store', async () => { + const stored = await upload('shot.png', Buffer.from('png-bytes')) + const outside = join(root, 'secret.png') + await writeFile(outside, 'secret') + await rm(stored.path) + await symlink(outside, stored.path) + await expect(store.readPreview(stored.path)).rejects.toThrow('Not an attachment image') + }) + + it('refuses an image over the reply budget instead of overflowing the connection', async () => { + const big = Buffer.alloc(3.5 * 1024 * 1024, 1) + const { uploadId } = await store.startUpload({ + callerKey: caller, + sessionId: 'session-1', + name: 'big.png', + byteLength: big.byteLength + }) + await store.appendChunk({ + callerKey: caller, + uploadId, + offset: 0, + contentBase64: big.toString('base64') + }) + const stored = await store.commitUpload({ callerKey: caller, uploadId }) + // The budget a remote reply gets: what the server's outbound limit leaves after the envelope. + await expect(store.readPreview(stored.path, REMOTE_RPC_MAX_CONTENT_BYTES)).rejects.toThrow( + 'file_too_large' + ) + // A local read keeps the larger preview limit. + await expect(store.readPreview(stored.path)).resolves.toMatchObject({ mimeType: 'image/png' }) + // An image that fits the budget also fits the server's outbound limit once enveloped. + const fits = await upload('fits.png', Buffer.alloc(2.5 * 1024 * 1024, 1)) + const preview = await store.readPreview(fits.path, REMOTE_RPC_MAX_CONTENT_BYTES) + const reply = JSON.stringify({ id: 'request-1', ok: true, result: preview }) + expect(isMobileE2EETextPayloadWithinLimit(reply)).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts new file mode 100644 index 000000000000..a9626e86afaf --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-store.ts @@ -0,0 +1,225 @@ +// The server-owned store for files a client attaches to a structured chat. +// +// `//`: one directory per upload keeps the user's file name +// intact (agents read meaning into it) without two uploads colliding. Bytes land in a hidden part +// file and are renamed into place on commit, so a stored name is always complete. Which chats hold +// an upload is recorded by the message that names it (`agent-session-attachment-claims.ts`). + +import { appendFile, mkdir, rm, writeFile } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { + sanitizeAgentSessionAttachmentName, + type AgentSessionAttachmentUploadCommitResult +} from '../../../shared/agent-session-attachments' +import type { RuntimeFilePreviewResult } from '../../../shared/runtime-types' +import { readAuthorizedDocPreviewFile } from '../../../shared/doc-preview-file-access' +import { renameFileWithWindowsRetryAsync } from '../../../shared/windows-retry-file-operations' +import { IMAGE_FILE_MIME_TYPES } from '../../../shared/image-file-extensions' +import { + LOCAL_PREVIEWABLE_BINARY_MAX_BYTES, + assertPreviewWithinTransportBudget, + previewableBinaryByteLimit +} from '../../runtime/runtime-file-commands-mobile-file-list-limit' +import { ChunkedUploadRegistry, nextChunkedUploadLength } from './chunked-upload-registry' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + parseAgentSessionAttachmentStorePath +} from './agent-session-attachment-references' + +const UPLOAD_MAX_CONCURRENT = 8 +const UPLOAD_IDLE_TTL_MS = 5 * 60 * 1000 +const NOT_FOUND = 'Attachment upload was not found' +const NOT_AN_IMAGE = 'Not an attachment image' +const PREVIEW_TOO_LARGE = 'file_too_large' + +function isTooLarge(error: unknown): boolean { + return error instanceof Error && error.message === PREVIEW_TOO_LARGE +} + +type InFlightUpload = { + callerKey: string + uploadDir: string + name: string + expectedLength: number + receivedLength: number + writing: boolean +} + +export type AgentSessionAttachmentStoreOptions = { + /** Whether this host holds the chat: an upload for any other chat is refused at its start. */ + hasSession: (sessionId: string) => boolean +} + +export class AgentSessionAttachmentStore { + private readonly uploads = new ChunkedUploadRegistry({ + maxConcurrent: UPLOAD_MAX_CONCURRENT, + ttlMs: UPLOAD_IDLE_TTL_MS, + tooManyMessage: 'Too many attachment uploads are in progress', + notFoundMessage: NOT_FOUND, + onExpire: (upload) => void removeQuietly(upload.uploadDir) + }) + + constructor( + readonly rootDir: string, + private readonly options: AgentSessionAttachmentStoreOptions + ) {} + + /** Upload ids whose bytes are still arriving; the sweep leaves their directories alone. */ + isUploadInFlight(uploadId: string): boolean { + return this.uploads.has(uploadId) + } + + async startUpload(args: { + callerKey: string + sessionId: string + name: string + byteLength: number + }): Promise<{ uploadId: string }> { + if (!this.options.hasSession(args.sessionId)) { + throw new Error('This chat is not on this host') + } + const name = sanitizeAgentSessionAttachmentName(args.name) + const uploadId = this.uploads.create((id) => ({ + callerKey: args.callerKey, + uploadDir: join(this.rootDir, id), + name, + expectedLength: args.byteLength, + receivedLength: 0, + writing: false + })) + const uploadDir = join(this.rootDir, uploadId) + try { + await mkdir(uploadDir, { recursive: true }) + await writeFile(join(uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), '', { flag: 'wx' }) + } catch (error) { + this.uploads.delete(uploadId) + await removeQuietly(uploadDir) + throw error + } + return { uploadId } + } + + async appendChunk(args: { + callerKey: string + uploadId: string + offset: number + contentBase64: string + }): Promise<{ receivedBytes: number }> { + const upload = this.requireOwned(args.uploadId, args.callerKey) + if (upload.writing) { + throw new Error('Attachment chunk is already being written') + } + const bytes = Buffer.from(args.contentBase64, 'base64') + const nextLength = nextChunkedUploadLength(upload, args.offset, bytes.byteLength, { + outOfOrder: 'Attachment chunk offset is out of order', + exceeded: 'Attachment upload exceeded its declared size' + }) + upload.writing = true + try { + await appendFile(join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), bytes) + upload.receivedLength = nextLength + } finally { + upload.writing = false + } + this.uploads.touch(args.uploadId) + return { receivedBytes: upload.receivedLength } + } + + async commitUpload(args: { + callerKey: string + uploadId: string + }): Promise { + const upload = this.requireOwned(args.uploadId, args.callerKey) + if (upload.writing) { + throw new Error('Attachment chunk is still being written') + } + // In flight until the rename lands, so the sweep never takes a slow upload's part file. + upload.writing = true + try { + if (upload.receivedLength !== upload.expectedLength) { + throw new Error('Attachment upload is incomplete') + } + const finalPath = join(upload.uploadDir, upload.name) + // An antivirus or indexer briefly holding the part file on Windows must not lose the upload. + await renameFileWithWindowsRetryAsync( + join(upload.uploadDir, AGENT_SESSION_ATTACHMENT_PART_FILE), + finalPath + ) + return { path: finalPath, name: upload.name, byteLength: upload.receivedLength } + } catch (error) { + await removeQuietly(upload.uploadDir) + throw error + } finally { + this.uploads.delete(args.uploadId) + } + } + + async abortUpload(args: { callerKey: string; uploadId: string }): Promise<{ aborted: true }> { + const upload = this.uploads.peek(args.uploadId) + if (upload) { + if (upload.callerKey !== args.callerKey) { + throw new Error(NOT_FOUND) + } + this.uploads.delete(args.uploadId) + await removeQuietly(upload.uploadDir) + } + return { aborted: true } + } + + /** + * A stored image's bytes, for a client that shows it. Anything outside the store is refused, and + * the read is protected and bounded: `maxContentBytes` is the reply's budget on a remote + * connection, and an image over it is refused as too large rather than sent. + */ + async readPreview(filePath: string, maxContentBytes?: number): Promise { + if ( + !IMAGE_FILE_MIME_TYPES[extname(filePath).toLowerCase()] || + !parseAgentSessionAttachmentStorePath(this.rootDir, filePath) + ) { + throw new Error(NOT_AN_IMAGE) + } + let read: Awaited> + try { + read = await readAuthorizedDocPreviewFile({ + boundaryPath: this.rootDir, + entryPath: filePath, + implicitRootPath: null, + authorizedRootPaths: [], + targetPath: filePath, + maxTextBytes: 0, + maxBinaryBytes: + maxContentBytes === undefined + ? LOCAL_PREVIEWABLE_BINARY_MAX_BYTES + : previewableBinaryByteLimit(maxContentBytes) + }) + } catch (error) { + // One answer for everything else, so a read says nothing about the server's other files. + throw new Error(isTooLarge(error) ? PREVIEW_TOO_LARGE : NOT_AN_IMAGE) + } + if (!read.isBinary || !read.mimeType?.startsWith('image/')) { + throw new Error(NOT_AN_IMAGE) + } + return assertPreviewWithinTransportBudget( + { content: read.content, isBinary: true, isImage: true, mimeType: read.mimeType }, + maxContentBytes + ) + } + + clearInFlightForTests(): void { + this.uploads.clear() + } + + private requireOwned(uploadId: string, callerKey: string): InFlightUpload { + const upload = this.uploads.require(uploadId) + // Another client's upload reads as absent, not as forbidden. + if (upload.callerKey !== callerKey) { + throw new Error(NOT_FOUND) + } + return upload + } +} + +export async function removeQuietly(path: string): Promise { + // Cleanup is best effort: a file that will not go is retried by the next sweep. + await rm(path, { recursive: true, force: true }).catch(() => {}) +} diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts new file mode 100644 index 000000000000..e414633a66bc --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.test.ts @@ -0,0 +1,199 @@ +import { mkdir, mkdtemp, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { + claimAgentSessionAttachmentsInTransaction, + isAgentSessionAttachmentExpiredError, + markUnclaimedUploadForSweep +} from './agent-session-attachment-claims' +import { + AGENT_SESSION_ATTACHMENT_PART_FILE, + agentSessionAttachmentStoreRoot +} from './agent-session-attachment-references' +import { AgentSessionAttachmentStore } from './agent-session-attachment-store' +import { + sweepAgentSessionAttachments, + type AgentSessionAttachmentSweepFacts +} from './agent-session-attachment-sweep' + +const HOUR = 60 * 60 * 1000 +const NOW = Date.now() +const OLD = 24 * HOUR + 1 +const SENT = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const UNSENT = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' +const CARRIED = '3e1f0a9b-8c7d-4e6f-a5b4-c3d2e1f0a903' + +let stateDirectory: string +let store: AgentSessionAttachmentStore +let db: Database.Database +let recorded: Set | null + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-sweep-')) + store = new AgentSessionAttachmentStore(agentSessionAttachmentStoreRoot(stateDirectory), { + hasSession: () => true + }) + db = openTestJournalHostDatabase(stateDirectory).db + recorded = new Set(['session-1', 'session-2']) +}) + +afterEach(async () => { + store.clearInFlightForTests() + closeTestJournalHostDatabases() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function facts(overrides: Partial = {}) { + return { database: () => db, recordedSessionIds: () => recorded, ...overrides } +} + +/** A stored upload (or a bare part file) last touched `ageMs` ago. */ +async function seedUpload( + uploadId: string, + ageMs: number, + file: string = 'shot.png' +): Promise { + const uploadDir = join(store.rootDir, uploadId) + await mkdir(uploadDir, { recursive: true }) + await writeFile(join(uploadDir, file), 'bytes') + const at = new Date(NOW - ageMs) + await utimes(uploadDir, at, at) + return join(uploadDir, file) +} + +function claim(sessionId: string, path: string, required = true): void { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path }] + } + db.exec('BEGIN') + try { + claimAgentSessionAttachmentsInTransaction(db, { + stateDirectory, + sessionId, + body, + required, + now: NOW + }) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +function claimedSessions(): string[] { + return db + .prepare('SELECT session_id FROM agent_session_attachment_claims ORDER BY session_id') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'session_id' in row ? [String(row.session_id)] : [] + ) +} + +async function remaining(): Promise { + return (await readdir(store.rootDir).catch(() => [])).sort() +} + +describe('sweepAgentSessionAttachments', () => { + it('removes a part file nobody finished after an hour, and keeps a fresh one', async () => { + await seedUpload(SENT, HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await seedUpload(UNSENT, HOUR - 1000, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([UNSENT]) + }) + + it('removes an upload nobody claimed after a day, and keeps a claimed one and a fresh one', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + await seedUpload(UNSENT, OLD) + await seedUpload(CARRIED, HOUR) + const { removed } = await sweepAgentSessionAttachments(store, facts(), NOW) + expect(removed).toEqual([join(store.rootDir, UNSENT)]) + expect(await remaining()).toEqual([SENT, CARRIED].sort()) + }) + + it('keeps an upload any chat still claims: a /clear carry is a second holder', async () => { + const path = await seedUpload(CARRIED, OLD) + claim('session-1', path) + claim('session-2', path, false) + // The source chat goes; the replacement still holds the upload. + recorded = new Set(['session-2']) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([CARRIED]) + expect(claimedSessions()).toEqual(['session-2']) + // Once no chat holds it, it is unclaimed and goes. + recorded = new Set() + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual([]) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([]) + }) + + it('a send after the sweep marked an upload is refused, never sent without its file', async () => { + const path = await seedUpload(UNSENT, OLD) + // The sweep wins the race: its mark lands before the claim. + expect(markUnclaimedUploadForSweep(db, UNSENT, NOW)).toBe(true) + let refused: unknown + try { + claim('session-1', path) + } catch (error) { + refused = error + } + expect(isAgentSessionAttachmentExpiredError(refused)).toBe(true) + expect(claimedSessions()).toEqual([]) + }) + + it('finishes a delete a crashed sweep left marked, then forgets the mark', async () => { + await seedUpload(UNSENT, HOUR) + // Marked, then the process died before the files went. + expect(markUnclaimedUploadForSweep(db, UNSENT, NOW)).toBe(true) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([]) + expect(db.prepare('SELECT COUNT(*) AS n FROM agent_session_attachment_sweeps').get()).toEqual({ + n: 0 + }) + }) + + it('judges no claim orphaned while the record list is incomplete', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + recorded = null + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual(['session-1']) + expect(await remaining()).toEqual([SENT]) + }) + + it('drops a claim whose upload was removed outside Orca', async () => { + claim('session-1', await seedUpload(SENT, OLD)) + await rm(join(store.rootDir, SENT), { recursive: true }) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(claimedSessions()).toEqual([]) + }) + + it('removes only part files when it cannot write claims (a newer Orca wrote the journal)', async () => { + await seedUpload(UNSENT, OLD) + await seedUpload(SENT, HOUR + 1, AGENT_SESSION_ATTACHMENT_PART_FILE) + await sweepAgentSessionAttachments(store, facts({ database: () => null }), NOW) + expect(await remaining()).toEqual([UNSENT]) + }) + + it('leaves an upload in flight alone however old its directory is', async () => { + const { uploadId } = await store.startUpload({ + callerKey: 'client-a', + sessionId: 'session-1', + name: 'a.png', + byteLength: 1 + }) + const at = new Date(NOW - OLD) + await utimes(join(store.rootDir, uploadId), at, at) + await sweepAgentSessionAttachments(store, facts(), NOW) + expect(await remaining()).toEqual([uploadId]) + }) +}) diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts new file mode 100644 index 000000000000..cb685a018939 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-sweep.ts @@ -0,0 +1,157 @@ +// When a stored attachment may go, re-derived on every sweep from the disk and the claim table. +// +// - A part file nobody is writing (the uploader crashed or vanished) goes after an hour. +// - An upload no message ever claimed goes after a day: its chip was removed or its draft +// abandoned. The sweep first marks it in the same database the claims live in, and only if no +// claim exists; a send that names it afterwards is refused instead of losing its file. +// - A claimed upload stays while any chat holding it has a record. The host never deletes a chat +// today, so that is as long as its transcript. +// - A mark left by a sweep that died mid-delete is finished by the next one. + +import { readdir, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type Database from '../../sqlite/sync-database' +import { + finishUploadSweep, + listUploadsBeingSwept, + markUnclaimedUploadForSweep, + pruneAgentSessionAttachmentClaims +} from './agent-session-attachment-claims' +import { AGENT_SESSION_ATTACHMENT_PART_FILE } from './agent-session-attachment-references' +import { removeQuietly, type AgentSessionAttachmentStore } from './agent-session-attachment-store' + +export const ABANDONED_PART_MAX_AGE_MS = 60 * 60 * 1000 +export const UNCLAIMED_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000 + +export type AgentSessionAttachmentSweepFacts = { + /** The host's journal connection, or null when it cannot take writes (closed, or written by a + * newer Orca): without it nothing claimed can be told apart, so only part files go. */ + database: () => Database.Database | null + /** Every chat this host holds a record for; null while that list is incomplete (records still + * owed their import), when no chat's claims can be judged orphaned. */ + recordedSessionIds: () => Pick, 'has'> | null +} + +export type AgentSessionAttachmentSweepResult = { removed: string[] } + +export async function sweepAgentSessionAttachments( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + now = Date.now() +): Promise { + const removed: string[] = [] + // Read again at every statement: the host may close the connection while the sweep awaits disk. + const { database } = facts + for (const uploadId of withDatabase(database, listUploadsBeingSwept) ?? []) { + await removeQuietly(join(store.rootDir, uploadId)) + withDatabase(database, (db) => finishUploadSweep(db, uploadId)) + } + for (const uploadId of await listDirectories(store.rootDir)) { + if (store.isUploadInFlight(uploadId)) { + continue + } + const uploadDir = join(store.rootDir, uploadId) + const stored = (await listEntries(uploadDir)).some( + (entry) => entry !== AGENT_SESSION_ATTACHMENT_PART_FILE + ) + const ageMs = now - (await modifiedAt(uploadDir)) + if (!stored) { + if (ageMs > ABANDONED_PART_MAX_AGE_MS) { + await removeQuietly(uploadDir) + removed.push(uploadDir) + } + continue + } + // A claim landing first keeps the upload; this mark landing first refuses that claim. + if ( + ageMs <= UNCLAIMED_ATTACHMENT_MAX_AGE_MS || + withDatabase(database, (db) => markUnclaimedUploadForSweep(db, uploadId, now)) !== true + ) { + continue + } + await removeQuietly(uploadDir) + withDatabase(database, (db) => finishUploadSweep(db, uploadId)) + removed.push(uploadDir) + } + withDatabase(database, (db) => + pruneAgentSessionAttachmentClaims(db, { + root: store.rootDir, + recordedSessionIds: facts.recordedSessionIds() + }) + ) + return { removed } +} + +function withDatabase( + database: AgentSessionAttachmentSweepFacts['database'], + run: (db: Database.Database) => T +): T | undefined { + const db = database() + return db ? run(db) : undefined +} + +async function listEntries(dir: string): Promise { + try { + return await readdir(dir) + } catch { + return [] + } +} + +async function listDirectories(dir: string): Promise { + try { + const entries = await readdir(dir, { withFileTypes: true }) + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name) + } catch { + return [] + } +} + +async function modifiedAt(path: string): Promise { + try { + return (await stat(path)).mtimeMs + } catch { + return Number.POSITIVE_INFINITY + } +} + +export type AgentSessionAttachmentSweeper = { stop: () => void } + +/** + * Sweeps shortly after the host comes up and then periodically. Best effort: a failed sweep is + * logged and the next one tries again; nothing waits on it. + */ +export function startAgentSessionAttachmentSweeps( + store: AgentSessionAttachmentStore, + facts: AgentSessionAttachmentSweepFacts, + options: { + initialDelayMs: number + intervalMs: number + onError: (error: unknown) => void + } +): AgentSessionAttachmentSweeper { + let running = false + let stopped = false + const run = (): void => { + if (running || stopped) { + return + } + running = true + void sweepAgentSessionAttachments(store, facts) + .catch(options.onError) + .finally(() => { + running = false + }) + } + const initial = setTimeout(run, options.initialDelayMs) + const interval = setInterval(run, options.intervalMs) + initial.unref?.() + interval.unref?.() + return { + stop: () => { + stopped = true + clearTimeout(initial) + clearInterval(interval) + } + } +} diff --git a/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts b/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts new file mode 100644 index 000000000000..729fc089c639 --- /dev/null +++ b/src/main/native-chat/agent-session-attachments/chunked-upload-registry.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'node:crypto' + +type RegistryEntry = { + value: T + expiresAt: number + ttlTimer: ReturnType +} + +export type ChunkedUploadRegistryOptions = { + maxConcurrent: number + /** Idle lifetime: every touch restarts it. */ + ttlMs: number + tooManyMessage: string + notFoundMessage: string + /** Runs when an upload expires unfinished; never for an explicit delete. */ + onExpire?: (value: T) => void +} + +/** + * In-flight chunked uploads held between start and commit: bounded in number, each forgotten after + * an idle TTL so a client that disappears mid-upload cannot pin the slot. + */ +export class ChunkedUploadRegistry { + private readonly entries = new Map>() + + constructor(private readonly options: ChunkedUploadRegistryOptions) {} + + get size(): number { + return this.entries.size + } + + /** `build` receives the new id, for state that is named after it. */ + create(build: (uploadId: string) => T): string { + this.pruneExpired() + if (this.entries.size >= this.options.maxConcurrent) { + throw new Error(this.options.tooManyMessage) + } + const uploadId = randomUUID() + this.entries.set(uploadId, { + value: build(uploadId), + expiresAt: Date.now() + this.options.ttlMs, + ttlTimer: this.scheduleExpiry(uploadId) + }) + return uploadId + } + + require(uploadId: string): T { + this.pruneExpired() + const entry = this.entries.get(uploadId) + if (!entry) { + throw new Error(this.options.notFoundMessage) + } + return entry.value + } + + peek(uploadId: string): T | undefined { + this.pruneExpired() + return this.entries.get(uploadId)?.value + } + + has(uploadId: string): boolean { + return this.entries.has(uploadId) + } + + touch(uploadId: string): void { + const entry = this.entries.get(uploadId) + if (!entry) { + return + } + clearTimeout(entry.ttlTimer) + entry.expiresAt = Date.now() + this.options.ttlMs + entry.ttlTimer = this.scheduleExpiry(uploadId) + } + + delete(uploadId: string): T | undefined { + const entry = this.entries.get(uploadId) + if (!entry) { + return undefined + } + clearTimeout(entry.ttlTimer) + this.entries.delete(uploadId) + return entry.value + } + + clear(): void { + for (const uploadId of this.entries.keys()) { + this.delete(uploadId) + } + } + + private expire(uploadId: string): void { + const value = this.delete(uploadId) + if (value !== undefined) { + this.options.onExpire?.(value) + } + } + + private pruneExpired(now = Date.now()): void { + for (const [uploadId, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.expire(uploadId) + } + } + } + + private scheduleExpiry(uploadId: string): ReturnType { + const timer = setTimeout(() => this.expire(uploadId), this.options.ttlMs) + if (typeof timer === 'object' && 'unref' in timer) { + timer.unref() + } + return timer + } +} + +/** The next chunk must start where the last one ended and stay within the declared size. */ +export function nextChunkedUploadLength( + upload: { expectedLength: number; receivedLength: number }, + offset: number, + chunkLength: number, + messages: { outOfOrder: string; exceeded: string } +): number { + if (offset !== upload.receivedLength) { + throw new Error(messages.outOfOrder) + } + const nextLength = upload.receivedLength + chunkLength + if (nextLength > upload.expectedLength) { + throw new Error(messages.exceeded) + } + return nextLength +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 04483d099583..ba961541a7e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -14,6 +14,7 @@ import { } from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' +import { ensureAgentSessionAttachmentClaimTables } from '../agent-session-attachments/agent-session-attachment-claims' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ @@ -80,6 +81,7 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) + ensureAgentSessionAttachmentClaimTables(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true return { db: probe, readOnly: false } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index e87de366fff2..aaea0228b6e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -28,14 +28,21 @@ export class JournalHostDatabase { /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false - private constructor(opened: OpenJournalDatabase) { + private constructor( + opened: OpenJournalDatabase, + /** Where the database lives; per-runtime end records sit beside it. */ + readonly stateDirectory: string + ) { this.connection = opened.db this.readOnly = opened.readOnly } static open(stateDirectory: string): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) - return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) + return new JournalHostDatabase( + openJournalDatabase(journalDatabasePath(stateDirectory)), + stateDirectory + ) } get isClosed(): boolean { 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 9614c7548ff6..5b0e2580c45e 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 @@ -16,7 +16,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' +import type { JournalOperationReceipt } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { holdQueuedMessages } from './queued-message-holds' import { @@ -45,6 +45,7 @@ import { settleQueuedMessagesForRow } from './queued-message-settlement' import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' +import type { JournalAttachmentClaim } from './journal-submission-hook' import type { JournalWriteBody, JournalWriteResult } from './journal-write-queue' /** Tombstones must outlive the window in which their operation id could still be admitted as new. */ @@ -66,6 +67,7 @@ export type JournalQueuedMessagesDeps = { * does — no call site can forget. In-transaction consume and the returned * transition already ride their row's own commit. */ committed: () => void + claimAttachments: JournalAttachmentClaim } export class JournalQueuedMessages { @@ -110,8 +112,10 @@ export class JournalQueuedMessages { } /** `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. */ + * `holdReason` carries a hold of its own over with it. + * `requireAttachments`: a client's own draft, refused whole when an attachment it names is no + * longer stored; the host's own writes (the carry) claim best effort. + * `receipt`: the send's ledger answer, committed with the draft only when this inserts it. */ insert( input: { messageId: string @@ -119,11 +123,13 @@ export class JournalQueuedMessages { fingerprint: string hostInstance: string carriedFrom?: string + requireAttachments?: true holdReason?: QueuedMessageHoldReason }, receipt?: JournalOperationReceipt ): Promise { const { sessionId } = this.deps + const { requireAttachments, ...draft } = input let inserted = false return this.transact( (db) => { @@ -133,10 +139,11 @@ export class JournalQueuedMessages { // gets here, so an existing row is the same accept landing twice. return existing } + this.deps.claimAttachments(db, input.body, requireAttachments === true) inserted = true const { epoch, lastSequence } = this.deps.state() const row = insertQueuedMessage(db, { - ...input, + ...draft, sessionId, queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() @@ -363,15 +370,6 @@ export class JournalQueuedMessages { } } -/** The per-append hook converting one draft inside the append's own transaction. */ -export function queuedMessageConsumeHook( - queuedMessages: JournalQueuedMessages, - consumedAs: string, - consume: JournalSubmissionConsume -): JournalRowTransactionHook { - return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs }) -} - export class QueuedMessageNotConsumableError extends Error { constructor( readonly messageId: string, 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 506547beb10f..5acba78506a4 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 @@ -23,6 +23,8 @@ 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' +import type { JournalAttachmentClaim } from './journal-submission-hook' +import { claimAgentSessionAttachmentsInTransaction } from '../agent-session-attachments/agent-session-attachment-claims' export type JournalStoreHost = { /** Fires the journal's commit listener for a durable change that appended no @@ -75,6 +77,15 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal cursor: host.cursor, adopt: host.adopt }) + const claimAttachments: JournalAttachmentClaim = (db, body, required) => + claimAgentSessionAttachmentsInTransaction(db, { + // Uploaded attachments are stored beside the journal database. + stateDirectory: host.database().stateDirectory, + sessionId: host.identity.sessionId, + body, + required, + now: host.now() + }) const queuedMessages = new JournalQueuedMessages({ sessionId: host.identity.sessionId, now: host.now, @@ -83,7 +94,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal readOnly: host.readOnly, state: host.state, reopenFloor: () => host.journal().reopenFloor(), - committed: host.notifyCommitted + committed: host.notifyCommitted, + claimAttachments }) const rowWriter = new JournalRowWriter({ sessionId: host.identity.sessionId, @@ -120,7 +132,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal state: host.state, identity: host.identity, rowWriter, - queuedMessages + queuedMessages, + claimAttachments }), itemAppender: new JournalItemAppender({ state: host.state, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts new file mode 100644 index 000000000000..84be3ab91c98 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts @@ -0,0 +1,30 @@ +import type Database from '../../sqlite/sync-database' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalRowTransactionHook } from './journal-row-writer' +import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' + +/** Claims the stored chat attachments a message written to this journal names, in the write's own + * transaction; `required` refuses the whole write when one is no longer stored. */ +export type JournalAttachmentClaim = ( + db: Database.Database, + body: AgentJournalMessageItem, + required: boolean +) => void + +/** The submission append's hook, inside its transaction: converts the draft it hands off, if any, + * and claims the stored attachments it names. A client's new message must name only attachments + * still stored; a draft's conversion was claimed when the draft was written. */ +export function journalSubmissionHook( + queuedMessages: JournalQueuedMessages, + claimAttachments: JournalAttachmentClaim, + input: Pick, + consume: JournalSubmissionConsume | undefined +): JournalRowTransactionHook { + return (db) => { + if (consume) { + queuedMessages.consumeInTransaction(db, { ...consume, consumedAs: input.clientMessageId }) + } + claimAttachments(db, input.body, input.origin === 'client' && !consume) + } +} 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 index c79fa6030315..1fc0ac21d0da 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -5,7 +5,8 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages } from './journal-queued-messages' +import { journalSubmissionHook, type JournalAttachmentClaim } from './journal-submission-hook' import type { JournalReducerState } from './journal-reducer' import { journalDispatchRowBuilder, journalSubmissionRowBuilder } from './journal-row-builders' import type { @@ -24,6 +25,7 @@ export type JournalSubmissionWriterDeps = { identity: AgentSessionJournalIdentity rowWriter: JournalRowWriter queuedMessages: JournalQueuedMessages + claimAttachments: JournalAttachmentClaim } export class JournalSubmissionWriter { @@ -42,10 +44,10 @@ export class JournalSubmissionWriter { /** The send's ledger answer, committed with this row. */ receipt?: JournalOperationReceipt ): Promise { - const { identity, queuedMessages, rowWriter, state } = this.deps + const { claimAttachments, identity, queuedMessages, rowWriter, state } = this.deps return rowWriter.append( journalSubmissionRowBuilder(state, identity, input, consume), - consume && queuedMessageConsumeHook(queuedMessages, input.clientMessageId, consume), + journalSubmissionHook(queuedMessages, claimAttachments, input, consume), receipt ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts new file mode 100644 index 000000000000..9228f6f0743f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -0,0 +1,145 @@ +// A message naming a chat attachment the host no longer stores is refused whole at admission, +// with a reason the client's existing rejected-send row puts into words, on the direct path and +// the queued one alike; a stored one is claimed, and a /clear carries the claim to its new chat. + +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { agentSessionAttachmentStoreRoot } from '../agent-session-attachments/agent-session-attachment-references' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +const UPLOAD = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' +const EXPIRED = { + ok: false, + refusal: { code: 'agent_session_operation_invalid', details: { reason: 'attachmentExpired' } } +} + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function storedPath(name: string): string { + return join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD, name) +} + +async function storeUpload(name: string): Promise { + await mkdir(join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD), { recursive: true }) + await writeFile(storedPath(name), 'bytes') + return storedPath(name) +} + +function clientSend(body: AgentJournalMessageItem, delivery?: 'queue-if-active') { + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + return rig.host.send(CALLER, { + envelope: rig.envelope(fields, 'agentSession.send', clientOperationId), + body, + ...(delivery ? { delivery } : {}), + userSend: true + }) +} + +function claimedSessions(): string[] { + return openTestJournalHostDatabase(rig.root) + .db.prepare('SELECT session_id FROM agent_session_attachment_claims ORDER BY session_id') + .all() + .flatMap((row) => + typeof row === 'object' && row !== null && 'session_id' in row ? [String(row.session_id)] : [] + ) +} + +function textWith(path: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `read @${path}` }] } +} + +describe('attachments at send admission', () => { + it('refuses a send naming an expired image before anything is recorded', async () => { + const result = await clientSend({ + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path: storedPath('shot.png') }] + }) + expect(result).toMatchObject(EXPIRED) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(0) + expect(rig.dispatch).not.toHaveBeenCalled() + }) + + it('refuses a queued send naming an expired file, and queues nothing', async () => { + await rig.workingSend() + expect(await clientSend(textWith(storedPath('notes.txt')), 'queue-if-active')).toMatchObject( + EXPIRED + ) + expect(await rig.drafts()).toHaveLength(0) + }) + + it('claims a stored upload, and the /clear carry claims it for the replacement chat', async () => { + const working = await rig.workingSend() + const path = await storeUpload('notes.txt') + expect(await clientSend(textWith(path), 'queue-if-active')).toMatchObject({ + ok: true, + value: { queued: { state: 'waiting' } } + }) + expect(claimedSessions()).toEqual([SESSION]) + await rig.stop() + await rig.settleAccepted(working, 'a') + const fields = { command: 'clear' as const } + const cleared = await rig.host.conversationCommand(CALLER, { + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()), + ...fields + }) + const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined + expect(replacementId).toBeTruthy() + expect(claimedSessions()).toEqual([SESSION, replacementId].sort()) + }) + + it('sends text that only mentions another store path, claiming nothing', async () => { + const result = await clientSend( + textWith(`/home/someone/.config/Orca/agent-session-attachments/${UPLOAD}/notes.txt`) + ) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + expect(claimedSessions()).toEqual([]) + }) + + it('replays an admitted send under the same id without judging its attachments again', async () => { + const path = await storeUpload('shot.png') + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path }] + } + const clientOperationId = hostTestOperationId() + const params = { + envelope: rig.envelope({ body }, 'agentSession.send', clientOperationId), + body, + userSend: true as const + } + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + // The upload goes; the outbox's resend of the same operation still answers as sent. + await rm(join(agentSessionAttachmentStoreRoot(rig.root), UPLOAD), { recursive: true }) + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + }) + + it("never refuses a host-side send (orchestration mail, a restart's continuation)", async () => { + const body = textWith(storedPath('notes.txt')) + const clientOperationId = hostTestOperationId() + const result = await rig.host.send(CALLER, { + envelope: rig.envelope({ body }, 'agentSession.send', clientOperationId), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) + }) +}) 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 fe8b31efe901..bceef9cb308e 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 @@ -31,6 +31,8 @@ import { } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' +import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' /** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the * serialized blocks); refused readably rather than trimmed. */ @@ -200,6 +202,8 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + /** A person's send at a chat surface: every attachment it names must still be stored. */ + userSend?: true } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -235,16 +239,25 @@ export async function maybeQueueStructuredAgentSessionSend( } // The insert notifies through the journal's commit listener: publication and // the drain re-derive with no call here to forget. - const row = await ctx.journal.queuedMessages.insert( - { - messageId: clientMessageId, - body: params.body, - // In the session that will send it: the reducer aliases the provider's echo by exactly this. - fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance() - }, - ctx.operationReceipt - ) + let row: QueuedMessageRow + try { + row = await ctx.journal.queuedMessages.insert( + { + messageId: clientMessageId, + body: params.body, + // In the session that will send it: the reducer aliases the provider's echo by exactly this. + fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), + hostInstance: structuredAgentSessionHostInstance(), + ...(params.userSend ? { requireAttachments: true } : {}) + }, + ctx.operationReceipt + ) + } catch (error) { + if (isAgentSessionAttachmentExpiredError(error)) { + return agentSessionAttachmentExpiredRefusal() + } + throw error + } return { ok: true, value: { 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 0fa49460d5e9..7ce3d43b4798 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 @@ -14,6 +14,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + userSend?: true }, immediate: () => Promise> ): Promise> { 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 4f575c1473be..200ce5a338ab 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 @@ -44,6 +44,7 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -86,6 +87,17 @@ function invalid( return { ok: false, refusal: refuse('agent_session_operation_invalid', { reason }, message) } } +/** A message naming an attachment this host no longer stores is refused whole, before delivery. */ +export function agentSessionAttachmentExpiredRefusal(): { + ok: false + refusal: AgentSessionWireRefusal +} { + return invalid( + 'attachmentExpired', + 'A chat attachment in this message is no longer stored on the host, so it was not sent.' + ) +} + /** A thrown adapter error is indistinguishable from a lost reply, so it settles as `unknown` * rather than as a rejection — unless the child had not proven its start. A dispatch to such a * child throws only when the start failed before the write (a write's own failure is an outcome, @@ -159,6 +171,9 @@ export async function performSend( ctx.operationReceipt ) } catch (error) { + if (isAgentSessionAttachmentExpiredError(error)) { + return agentSessionAttachmentExpiredRefusal() + } // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the // journal already classified (a failed transaction that will not roll back). diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 9f87aab0b906..3370db3f2de5 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -49,6 +49,7 @@ import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' import { STRUCTURED_AGENT_SESSION_VISUAL_METHODS } from './structured-agent-session-visual' import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' import { ARTIFACT_METHODS } from './artifacts' @@ -70,6 +71,7 @@ export const ALL_RPC_METHODS = [ ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, ...STRUCTURED_AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS, ...STRUCTURED_AGENT_SESSION_VISUAL_METHODS, ...STRUCTURED_AGENT_SESSION_AGENTS_METHODS, ...AGENT_LAUNCH_METHODS, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts new file mode 100644 index 000000000000..e2b33bc9bb6a --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, readFile, 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 { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from '../../../../shared/protocol-version' +import { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { setAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS } from './structured-agent-session-attachments' +import { isMobileE2EETextPayloadWithinLimit } from '../mobile-e2ee-outbound-admission' +import { + clearStructuredHostStub, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +let root: string +let store: AgentSessionAttachmentStore + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-attachment-rpc-')) + store = new AgentSessionAttachmentStore(join(root, 'agent-session-attachments'), { + hasSession: (sessionId) => sessionId === 'session-alpha' + }) + setAgentSessionAttachmentStore(store) + installStructuredHostStub() +}) + +afterEach(async () => { + store.clearInFlightForTests() + setAgentSessionAttachmentStore(null) + clearStructuredHostStub() + await rm(root, { recursive: true, force: true }) +}) + +async function call( + method: string, + params: unknown, + client: { clientId?: string; clientKind?: 'runtime' | 'mobile'; clientCapabilities?: string[] } +): Promise { + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attachment methods read only the host-install hook and runtime id. + runtime: { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => {} + } as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS + }) + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { id: 'request-1', authToken: 'token', method, params }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the dispatcher writes serialized RpcResponse frames. + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + if (!replies[0]) { + throw new Error(`no reply for ${method}`) + } + return replies[0] +} + +function result(response: RpcResponse): T { + if (!response.ok) { + throw new Error(`refused: ${JSON.stringify(response)}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each caller names the result shape its method returns. + return response.result as T +} + +const CLIENT_A = { ...STRUCTURED_CLIENT, clientId: 'client-a' } + +async function uploadBytes(name: string, bytes: Buffer): Promise { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name, byteLength: bytes.byteLength }, + CLIENT_A + ) + ) + const chunk = 384 * 1024 + for (let offset = 0; offset < bytes.byteLength; offset += chunk) { + result( + await call( + 'agentSessionAttachment.uploadAppend', + { + uploadId, + offset, + contentBase64: bytes.subarray(offset, offset + chunk).toString('base64') + }, + CLIENT_A + ) + ) + } + return result<{ path: string }>( + await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) + ).path +} + +describe('agentSessionAttachment.*', () => { + it('is advertised, so a client can tell a host with the store from an older one', () => { + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY) + }) + + it('stores an upload for the chat and hands back the server path', async () => { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'shot.png', byteLength: 3 }, + CLIENT_A + ) + ) + result( + await call( + 'agentSessionAttachment.uploadAppend', + { uploadId, offset: 0, contentBase64: Buffer.from('png').toString('base64') }, + CLIENT_A + ) + ) + const stored = result<{ path: string; name: string }>( + await call('agentSessionAttachment.uploadCommit', { uploadId }, CLIENT_A) + ) + expect(stored.name).toBe('shot.png') + expect(stored.path.startsWith(store.rootDir)).toBe(true) + expect(await readFile(stored.path, 'utf8')).toBe('png') + + const preview = await call('agentSessionAttachment.read', { path: stored.path }, CLIENT_A) + expect(preview).toMatchObject({ ok: true, result: { isBinary: true, mimeType: 'image/png' } }) + }) + + it('refuses a client that cannot read structured sessions', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'shot.png', byteLength: 3 }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + }) + + it('refuses a declared size over the attachment limit before storing anything', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'big.bin', byteLength: 51 * 1024 * 1024 }, + CLIENT_A + ) + expect(response.ok).toBe(false) + }) + + it("keeps one client's upload out of another's reach", async () => { + const { uploadId } = result<{ uploadId: string }>( + await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-alpha', name: 'a.txt', byteLength: 1 }, + CLIENT_A + ) + ) + const response = await call( + 'agentSessionAttachment.uploadCommit', + { uploadId }, + { + ...STRUCTURED_CLIENT, + clientId: 'client-b' + } + ) + expect(response).toMatchObject({ ok: false }) + expect(store.isUploadInFlight(uploadId)).toBe(true) + }) + + it('refuses an upload for a chat this host does not hold', async () => { + const response = await call( + 'agentSessionAttachment.uploadStart', + { sessionId: 'session-elsewhere', name: 'a.txt', byteLength: 1 }, + CLIENT_A + ) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('not on this host') } + }) + }) + + it('answers a remote preview of an image over 3 MiB with a small refusal, not an oversized reply', async () => { + const path = await uploadBytes('big.png', Buffer.alloc(3.5 * 1024 * 1024, 7)) + const replies: string[] = [] + const dispatcher = new RpcDispatcher({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attachment methods read only the host-install hook and runtime id. + runtime: { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => {} + } as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS + }) + await dispatcher.dispatchStreaming( + { + id: 'request-big', + authToken: 'token', + method: 'agentSessionAttachment.read', + params: { path } + }, + (raw) => replies.push(raw), + { ...CLIENT_A, clientKind: 'runtime' } + ) + expect(replies).toHaveLength(1) + expect(isMobileE2EETextPayloadWithinLimit(replies[0] ?? '')).toBe(true) + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('file_too_large') } + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts new file mode 100644 index 000000000000..2c45d75c662e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-attachments.ts @@ -0,0 +1,76 @@ +// `agentSessionAttachment.*` — files a client attaches to a structured chat, stored on this host. +// +// Behind the same gate as `agentSession.*`: only a client that reads structured sessions may write +// into their store. A namespace of its own, because these reach the attachment store rather than +// the session host. Clients call them only where the host advertises +// `agent-session.attachments.v1`; an older host answers `method_not_found`. + +import { defineMethod, type RpcContext } from '../core' +import { + requireInstalledStructuredHost, + structuredCallerFor +} from './structured-agent-session-gate' +import { + AttachmentReadParams, + AttachmentUploadAppendParams, + AttachmentUploadIdParams, + AttachmentUploadStartParams +} from '../../../../shared/rpc-contract/agent-session-attachment-params' +import { remoteFileContentBudget } from './files-remote-content-budget' +import type { AgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store' +import { getAgentSessionAttachmentStore } from '../../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' + +async function requireStore( + ctx: RpcContext +): Promise<{ store: AgentSessionAttachmentStore; callerKey: string }> { + await requireInstalledStructuredHost(ctx) + const store = getAgentSessionAttachmentStore() + if (!store) { + throw new Error('Chat attachments are not available on this host') + } + return { store, callerKey: structuredCallerFor(ctx).callerKey } +} + +export const STRUCTURED_AGENT_SESSION_ATTACHMENT_METHODS = [ + defineMethod({ + name: 'agentSessionAttachment.uploadStart', + params: AttachmentUploadStartParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.startUpload({ callerKey, ...params }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadAppend', + params: AttachmentUploadAppendParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.appendChunk({ callerKey, ...params }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadCommit', + params: AttachmentUploadIdParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.commitUpload({ callerKey, uploadId: params.uploadId }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.uploadAbort', + params: AttachmentUploadIdParams, + handler: async (params, ctx) => { + const { store, callerKey } = await requireStore(ctx) + return store.abortUpload({ callerKey, uploadId: params.uploadId }) + } + }), + defineMethod({ + name: 'agentSessionAttachment.read', + params: AttachmentReadParams, + handler: async (params, ctx) => { + const { store } = await requireStore(ctx) + // A remote reply must fit the connection's outbound budget, or the server closes the socket. + return store.readPreview(params.path, remoteFileContentBudget(ctx.clientKind, ctx.requestId)) + } + }) +] diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a0c0089c8d33..20d9079bd117 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -17,6 +17,7 @@ import type { StructuredAgentDefinition } from '../native-chat/agent-session-wir import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { readClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { replayJournal } from '../native-chat/agent-session-journal/journal-open' import type { AgentSessionRecordStore } from './agent-session-record-store' @@ -140,6 +141,7 @@ function createClaudeAdapter( readClaudeManagedAccountGateSettings(deps.getClaudeManagedAccountGateSettings!) } : {}), + attachmentDirectory: agentSessionAttachmentStoreRoot(deps.stateDirectory), onLifecycleEvent: context.deliverLifecycle, logger: deps.logger, onChildWorkEvidence: (sessionId, evidence) => diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.test.ts b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts new file mode 100644 index 000000000000..02323d20fc80 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.test.ts @@ -0,0 +1,67 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type Database from '../sqlite/sync-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' + +let stateDirectory: string + +beforeEach(async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-wiring-')) +}) + +afterEach(async () => { + stopAgentSessionAttachments() + await rm(stateDirectory, { recursive: true, force: true }) +}) + +function install(directory = stateDirectory): void { + const records: Pick = { + getRecord: () => null, + isSessionUnreadable: () => false + } + // Written by a newer Orca: the sweep reads no claims, so nothing here touches the connection. + const journal: Pick = { + readOnly: true, + isClosed: false, + get db(): Database.Database { + throw new Error('not opened in this test') + } + } + installAgentSessionAttachments({ + stateDirectory: directory, + store: records, + journalDatabase: journal, + logger: createStructuredAgentSessionLogger() + }) +} + +describe('installAgentSessionAttachments', () => { + // Claude takes the store as an added directory only if it exists when Claude starts. + it('creates the store root the Claude launch is granted, before any upload', () => { + install() + const root = agentSessionAttachmentStoreRoot(stateDirectory) + expect(existsSync(root)).toBe(true) + expect(getAgentSessionAttachmentStore()?.rootDir).toBe(root) + }) + + it('still installs the store when the root cannot be created', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // A file where the state directory should be: nothing can be created under it. + const blocked = join(stateDirectory, 'not-a-directory') + await writeFile(blocked, '') + expect(() => install(blocked)).not.toThrow() + expect(getAgentSessionAttachmentStore()?.rootDir).toBe(agentSessionAttachmentStoreRoot(blocked)) + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/structured-agent-session-attachment-wiring.ts b/src/main/runtime/structured-agent-session-attachment-wiring.ts new file mode 100644 index 000000000000..9ee32ec10f41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-attachment-wiring.ts @@ -0,0 +1,68 @@ +// Installs the chat attachment store beside the structured host, and its sweeps. The store lives in +// the same state directory as the journal, whose database holds the claims the sweeps honor. + +import { mkdirSync } from 'node:fs' +import { AgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store' +import { setAgentSessionAttachmentStore } from '../native-chat/agent-session-attachments/agent-session-attachment-store-registry' +import { + startAgentSessionAttachmentSweeps, + type AgentSessionAttachmentSweeper +} from '../native-chat/agent-session-attachments/agent-session-attachment-sweep' +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +const FIRST_SWEEP_DELAY_MS = 60 * 1000 +const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000 + +let sweeper: AgentSessionAttachmentSweeper | null = null + +export function installAgentSessionAttachments(deps: { + stateDirectory: string + store: Pick + journalDatabase: Pick + logger: StructuredAgentSessionLogger +}): void { + stopAgentSessionAttachments() + const root = agentSessionAttachmentStoreRoot(deps.stateDirectory) + // Before any Claude starts: Claude drops an added directory that does not exist yet, for good. + try { + mkdirSync(root, { recursive: true }) + } catch (error) { + deps.logger.warn('chat attachment store could not be created', { + scope: 'attachment-store', + error + }) + } + const attachments = new AgentSessionAttachmentStore(root, { + hasSession: (sessionId) => deps.store.getRecord(sessionId) !== null + }) + setAgentSessionAttachmentStore(attachments) + sweeper = startAgentSessionAttachmentSweeps( + attachments, + { + database: () => + deps.journalDatabase.readOnly || deps.journalDatabase.isClosed + ? null + : deps.journalDatabase.db, + recordedSessionIds: () => ({ + // Readable or not: an unreadable chat's claims still protect its uploads. + has: (sessionId) => + deps.store.getRecord(sessionId) !== null || deps.store.isSessionUnreadable(sessionId) + }) + }, + { + initialDelayMs: FIRST_SWEEP_DELAY_MS, + intervalMs: SWEEP_INTERVAL_MS, + onError: (error) => + deps.logger.warn('chat attachment sweep failed', { scope: 'attachment-sweep', error }) + } + ) +} + +export function stopAgentSessionAttachments(): void { + sweeper?.stop() + sweeper = null + setAgentSessionAttachmentStore(null) +} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d8a5698b0254..d2d7b3b21511 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -32,6 +32,10 @@ import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-sessio import { StructuredAgentRegistry } from '../native-chat/agent-session-wire/structured-agent-registry' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' +import { + installAgentSessionAttachments, + stopAgentSessionAttachments +} from './structured-agent-session-attachment-wiring' import { openAgentSessionRecordStoreOnce, releaseAgentSessionRecordStore, @@ -195,6 +199,7 @@ export async function stopStructuredAgentSessionRuntime(options?: { const pending = installing installing = null setStructuredAgentSessionHost(null) + stopAgentSessionAttachments() const outstanding = [...pendingTeardown] pendingTeardown.clear() const installed = pending ? await pending.catch(() => null) : null @@ -322,6 +327,12 @@ async function installOnJournal( }) } setStructuredAgentSessionHost(host) + installAgentSessionAttachments({ + stateDirectory: deps.stateDirectory, + store, + journalDatabase, + logger: deps.logger + }) return { host, adapter, diff --git a/src/main/runtime/structured-claude-attachment-grant.test.ts b/src/main/runtime/structured-claude-attachment-grant.test.ts new file mode 100644 index 000000000000..9971f93ccdbf --- /dev/null +++ b/src/main/runtime/structured-claude-attachment-grant.test.ts @@ -0,0 +1,82 @@ +// The chat attachment store reaches Claude's launch as an added directory: the runtime hands its +// root to the Claude adapter, and the adapter to the launch resolver. Drop either hand-off and +// Claude asks before reading every attached document. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as LaunchResolution from '../claude/claude-structured-launch-resolution' +import type * as ClaudeRuntimeAdapter from './structured-claude-runtime-adapter' + +type GrantDeps = { attachmentDirectory?: string } + +const captured = vi.hoisted(() => { + const adapter: GrantDeps[] = [] + const resolver: GrantDeps[] = [] + return { adapter, resolver } +}) + +vi.mock('./structured-claude-runtime-adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createStructuredClaudeRuntimeAdapter: ( + deps: Parameters[0] + ) => { + captured.adapter.push(deps) + return actual.createStructuredClaudeRuntimeAdapter(deps) + } + } +}) + +vi.mock('../claude/claude-structured-launch-resolution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createClaudeStructuredLaunchResolver: ( + deps: Parameters[0] + ) => { + captured.resolver.push(deps) + return actual.createClaudeStructuredLaunchResolver(deps) + } + } +}) + +import { agentSessionAttachmentStoreRoot } from '../native-chat/agent-session-attachments/agent-session-attachment-references' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let stateDirectory: string | null = null + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } +}) + +describe("structured Claude's read grant for chat attachments", () => { + it('hands the store root from the runtime through the adapter to the launch resolver', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-attachment-grant-')) + const directory = stateDirectory + await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: directory, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => directory, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveLaunchArgs: () => [], + resolveEnvironment: async () => ({}) + }) + + const root = agentSessionAttachmentStoreRoot(directory) + expect(captured.adapter.at(-1)?.attachmentDirectory).toBe(root) + expect(captured.resolver.at(-1)?.attachmentDirectory).toBe(root) + }) +}) diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 288c7a7ddd59..04f04a50ccc8 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -37,6 +37,8 @@ export type StructuredClaudeRuntimeAdapterDeps = { /** The user's Agent Permissions setting for Claude; absent means prompting. */ resolveClaudePermissionMode?: () => Promise | PermissionMode readClaudeManagedAccountGate?: () => ClaudeManagedAccountGateSettings | null + /** Where the host stores chat attachments; granted to the agent as a readable directory. */ + attachmentDirectory?: string openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] readProcessStartTime?: ClaudeStructuredSessionAdapterDeps['readProcessStartTime'] modelCatalog?: ClaudeStructuredSessionAdapterDeps['modelCatalog'] @@ -102,6 +104,7 @@ export function createStructuredClaudeRuntimeAdapter( ...(deps.readClaudeManagedAccountGate ? { readManagedAccountGate: deps.readClaudeManagedAccountGate } : {}), + ...(deps.attachmentDirectory ? { attachmentDirectory: deps.attachmentDirectory } : {}), ...(deps.claudeThinkingDisplay ? { thinkingDisplay: deps.claudeThinkingDisplay } : {}) }), persistHandle: async ({ sessionId, providerSessionId, leafUuid, fence }) => { diff --git a/src/main/window/clipboard-agent-session-attachment-paste.test.ts b/src/main/window/clipboard-agent-session-attachment-paste.test.ts new file mode 100644 index 000000000000..8db77235abf8 --- /dev/null +++ b/src/main/window/clipboard-agent-session-attachment-paste.test.ts @@ -0,0 +1,120 @@ +// A structured chat on a paired server keeps pasted images in that server's attachment store, not +// in its temp directory: a temp path has no owner and nothing ever removes it. +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + handle: vi.fn(), + readImage: vi.fn(), + callRuntimeEnvironment: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/client-user-data') }, + clipboard: { readImage: mocks.readImage }, + ipcMain: { removeHandler: vi.fn(), handle: mocks.handle }, + nativeImage: { createFromBuffer: vi.fn() } +})) +vi.mock('../ipc/runtime-environment-transport-routing', () => ({ + callRuntimeEnvironment: mocks.callRuntimeEnvironment +})) +vi.mock('../../shared/runtime-environment-store', () => ({ + resolveEnvironment: (_userDataPath: string, selector: string) => ({ id: selector }) +})) +vi.mock('./clipboard-remote-file-copy', () => ({ + cleanupExpiredRemoteClipboardFiles: vi.fn(async () => {}), + scheduleLegacyRemoteClipboardFileCleanup: vi.fn(), + writeRemoteFileToClipboard: vi.fn() +})) +vi.mock('./dashboard-popout-window', () => ({ isDashboardPopoutRenderer: () => false })) + +import { + registerClipboardHandlers, + setTrustedClipboardRendererWebContentsId +} from './clipboard-ipc-handlers' + +function saveHandler(): (...args: unknown[]) => Promise { + const entry = mocks.handle.mock.calls.find( + ([channel]) => channel === 'clipboard:saveImageAsTempFile' + ) + if (!entry) { + throw new Error('save handler was not registered') + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle was called with this channel's async handler. + return entry[1] as (...args: unknown[]) => Promise +} + +const event = { + sender: { + id: 17, + getType: () => 'window', + getURL: () => 'file:///orca/index.html', + isDestroyed: () => false + } +} + +beforeEach(() => { + vi.clearAllMocks() + setTrustedClipboardRendererWebContentsId(17) + mocks.readImage.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => Buffer.from('png') + }) + mocks.callRuntimeEnvironment.mockImplementation(async (_userData, _env, method: string) => { + if (method === 'agentSessionAttachment.uploadStart') { + return { ok: true, result: { uploadId: 'upload-1' } } + } + if (method === 'agentSessionAttachment.uploadAppend') { + return { ok: true, result: { receivedBytes: 3 } } + } + if (method === 'agentSessionAttachment.uploadCommit') { + return { + ok: true, + result: { path: '/srv/agent-session-attachments/s/upload-1/p.png', name: 'p.png' } + } + } + throw new Error(`unexpected method: ${method}`) + }) +}) + +describe('clipboard:saveImageAsTempFile for a structured chat on a paired server', () => { + it("stores the image in the chat's attachment store, pinned to the pairing and server", async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. + registerClipboardHandlers({} as never) + + await expect( + saveHandler()(event, { + runtimeEnvironmentId: 'remote-host-1', + agentSessionAttachment: { + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + ).resolves.toBe('/srv/agent-session-attachments/s/upload-1/p.png') + + const methods = mocks.callRuntimeEnvironment.mock.calls.map((call) => call[2]) + expect(methods).toEqual([ + 'agentSessionAttachment.uploadStart', + 'agentSessionAttachment.uploadAppend', + 'agentSessionAttachment.uploadCommit' + ]) + expect(mocks.callRuntimeEnvironment.mock.calls[0][3]).toMatchObject({ + sessionId: 'session-1', + name: expect.stringMatching(/^orca-paste-.+\.png$/), + byteLength: 3 + }) + for (const call of mocks.callRuntimeEnvironment.mock.calls) { + expect(call[5]).toBe(7) + expect(call[7]).toMatchObject({ expectedEnvironmentRuntimeId: 'runtime-a' }) + } + }) + + it('keeps the server temp directory for a paste that names no chat', async () => { + mocks.callRuntimeEnvironment.mockResolvedValue({ ok: true, result: { uploadId: 'u' } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the image save path never reads the store. + registerClipboardHandlers({} as never) + await saveHandler()(event, { runtimeEnvironmentId: 'remote-host-1' }).catch(() => {}) + expect(mocks.callRuntimeEnvironment.mock.calls[0][2]).toBe('clipboard.startImageUpload') + }) +}) diff --git a/src/main/window/clipboard-image-temp-file.ts b/src/main/window/clipboard-image-temp-file.ts index 3a599e4ac5e2..223b89c79a63 100644 --- a/src/main/window/clipboard-image-temp-file.ts +++ b/src/main/window/clipboard-image-temp-file.ts @@ -6,11 +6,14 @@ import { requireSshFilesystemProvider } from '../providers/ssh-filesystem-dispat import { getAppEnvironment } from '../../shared/app-environment' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { assertClipboardImageByteLengthWithinLimit } from '../../shared/clipboard-image' +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { nativeChatPasteFolder } from './native-chat-paste-files' export type SaveClipboardImageAsTempFileArgs = { connectionId?: string | null runtimeEnvironmentId?: string | null + /** With `runtimeEnvironmentId`: store the image as an attachment of this structured chat. */ + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste: kept in Orca's paste folder so its draft can bring it back. */ forNativeChatDraft?: boolean } diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f5fb9032f2dc..05a09a54bb81 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -39,6 +39,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +import { uploadPastedImageToAgentSessionAttachments } from '../ipc/agent-session-attachment-upload' import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' @@ -59,6 +60,15 @@ async function saveClipboardImageBufferForTarget( ): Promise { assertClipboardImageByteLengthWithinLimit(buffer.byteLength) const runtimeEnvironmentId = args?.runtimeEnvironmentId?.trim() + // A structured chat on a paired server keeps its pasted images in that server's store. + if (runtimeEnvironmentId && args?.agentSessionAttachment) { + return uploadPastedImageToAgentSessionAttachments( + args.agentSessionAttachment, + runtimeEnvironmentId, + app.getPath('userData'), + buffer + ) + } // Why (#17679): with a runtime owner, a connectionId names one of the RUNTIME's SSH // connections (nested Remote Server -> SSH), not one this process dialed. Looking it up // in the local provider registry can only miss, so the runtime must perform the save. diff --git a/src/preload/api/filesystem-api.ts b/src/preload/api/filesystem-api.ts index 737a1b87594a..5e822328195a 100644 --- a/src/preload/api/filesystem-api.ts +++ b/src/preload/api/filesystem-api.ts @@ -27,6 +27,10 @@ import type { PythonEnvironments } from '../../shared/notebook-kernel-types' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' import type { PrepareDroppedPathsRequest, PreparedDroppedPaths @@ -201,6 +205,10 @@ export type FilesystemApi = { connectionId?: string } & SshMutationExpectation ) => Promise + /** Uploads client-local files into a paired server's store for one structured chat. */ + uploadPathsToAgentSessionAttachments: ( + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ) => Promise watchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise unwatchWorktree: (args: { worktreePath: string; connectionId?: string }) => Promise onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void diff --git a/src/preload/api/fs-bridge.ts b/src/preload/api/fs-bridge.ts index c60917d427a1..6fc8e9b69d8c 100644 --- a/src/preload/api/fs-bridge.ts +++ b/src/preload/api/fs-bridge.ts @@ -7,6 +7,10 @@ import type { import type { SshMutationExpectation } from '../../shared/ssh-types' import type { LocalFileAccess } from '../../shared/local-file-access' import type { RuntimeUploadFileStreamRequest } from '../../shared/runtime-upload-staging-contract' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../shared/agent-session-attachments' import type { SearchResult } from '../../shared/code-search-types' import type { FsChangedPayload } from '../../shared/filesystem-entry-types' import type { @@ -211,6 +215,10 @@ export const fsApi = { } & SshMutationExpectation ): Promise => ipcRenderer.invoke('fs:resolveDroppedPathsForAgent', args), + uploadPathsToAgentSessionAttachments: ( + args: AgentSessionAttachmentUploadTarget & { paths: string[] } + ): Promise => + ipcRenderer.invoke('fs:uploadPathsToAgentSessionAttachments', args), watchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('fs:watchWorktree', args), unwatchWorktree: (args: { worktreePath: string; connectionId?: string }): Promise => diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index b7d9345daf70..68dd74dd0b0f 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import { ipcRenderer, webFrame } from 'electron' import type { RuntimeMobileMarkdownRequest, @@ -93,6 +94,7 @@ export const uiClipboardAndWindowControlsApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste, kept where its draft can bring it back. */ forNativeChatDraft?: boolean }): Promise => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args), diff --git a/src/preload/api/ui-window-api.ts b/src/preload/api/ui-window-api.ts index e18fa1513948..1baa264a784a 100644 --- a/src/preload/api/ui-window-api.ts +++ b/src/preload/api/ui-window-api.ts @@ -1,3 +1,4 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../shared/agent-session-attachments' import type { ClipboardImageThumbnail } from '../../shared/clipboard-image' import type { ReadClipboardTextOptions } from '../../shared/clipboard-text' import type { NativeFileDropPayload } from '../../shared/native-file-drop' @@ -12,6 +13,7 @@ export type UiWindowApi = { saveClipboardImageAsTempFile: (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget /** A native-chat composer paste, kept where its draft can bring it back. */ forNativeChatDraft?: boolean }) => Promise diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx new file mode 100644 index 000000000000..f5509b5b10df --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.paste-remount.test.tsx @@ -0,0 +1,256 @@ +// @vitest-environment happy-dom + +// A screenshot pasted into a chat on a paired server is still uploading when an agent prompt card +// replaces the composer. The real composer, attachment and paste hooks keep the upload, and Send +// waits for it, whenever the composer comes back; a rich-text paste's image is owed from the start. + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ClipboardEventLike } from './native-chat-clipboard-payload' + +type FieldProps = { + onPaste?: (event: ClipboardEventLike) => void + imageAttachments?: { path: string; pending?: boolean }[] + sendButtonDisabled?: boolean +} + +const mocks = vi.hoisted(() => { + const state: { fieldProps: FieldProps | null } = { fieldProps: null } + return { state, prepare: vi.fn(), save: vi.fn() } +}) + +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + agentStatusByPaneKey: {}, + updateSettings: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchDraftAdopted: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ getSettingsForAgentTabRuntimeOwner: () => ({}) })) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) +vi.mock('./NativeChatComposerField', () => ({ + NativeChatComposerField: (props: FieldProps) => { + mocks.state.fieldProps = props + return
+ } +})) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) +vi.mock('./use-native-chat-external-attachments', () => ({ + useNativeChatExternalAttachments: () => ({ + attachExternalPaths: vi.fn(), + resolveAttachmentOwner: () => ({ + kind: 'runtime-session', + environmentId: 'env-1', + pairingRevision: 1, + sessionId: 'session-1' + }) + }) +})) +vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ + ...(await importOriginal()), + prepareNativeChatSessionAttachmentUpload: mocks.prepare +})) + +import { NativeChatComposer } from './NativeChatComposer' +import { + clearNativeChatDraftCacheForTests, + writeNativeChatDraftCache +} from './native-chat-draft-cache' +import { + clearNativeChatAttachmentCacheForTests, + readNativeChatAttachmentCache +} from './use-native-chat-composer-attachments' + +const PANE = 'tab-1::session-1' +const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/orca-paste.png' + +function composer(): React.JSX.Element { + return ( + true), + dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'remote', + sessionId: 'session-1', + runtimeEnvironmentId: 'env-1' + }} + /> + ) +} + +/** The preload bridge: what this test drives, and an inert subscription for everything else. */ +function installPreloadApi(ui: Record): void { + const inert = (): (() => void) => () => {} + const namespace = (own: Record): Record => + new Proxy(own, { + get: (target, key) => (typeof key === 'string' && key in target ? target[key] : inert) + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: new Proxy>( + { ui: namespace(ui) }, + { get: (target, key) => (key === 'ui' ? target.ui : namespace({})) } + ) + }) +} + +function imagePaste(text?: string): ClipboardEventLike { + const data = new DataTransfer() + data.items.add(new File(['image'], 'image.png', { type: 'image/png' })) + if (text) { + data.setData('text/plain', text) + } + return new ClipboardEvent('paste', { clipboardData: data, cancelable: true }) +} + +function holdUpload(): (path: string) => void { + let finishUpload: (path: string) => void = () => {} + mocks.save.mockReturnValue( + new Promise((resolve) => { + finishUpload = resolve + }) + ) + installPreloadApi({ saveClipboardImageAsTempFile: mocks.save }) + return (path) => finishUpload(path) +} + +beforeEach(() => { + clearNativeChatAttachmentCacheForTests() + clearNativeChatDraftCacheForTests() + mocks.state.fieldProps = null + mocks.prepare.mockResolvedValue({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 1, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + vi.stubGlobal( + 'URL', + Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} }) + ) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('a paste into a chat on a paired server', () => { + it('is still there when the composer returns from a prompt card that replaced it mid-upload', async () => { + let finishUpload: (path: string) => void = () => {} + mocks.save.mockReturnValue( + new Promise((resolve) => { + finishUpload = resolve + }) + ) + installPreloadApi({ saveClipboardImageAsTempFile: mocks.save }) + const view = render(composer()) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + + // The agent asks a question: its card takes the composer's place while the image uploads. + view.unmount() + await act(async () => finishUpload(STORED)) + + expect(readNativeChatAttachmentCache(PANE).map((attachment) => attachment.path)).toEqual([ + STORED + ]) + render(composer()) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) + }) + + it('still holds Send when the composer returns before the upload has finished', async () => { + const finishUpload = holdUpload() + // Something typed, so only the pending image can hold Send. + writeNativeChatDraftCache(PANE, 'look at this') + const first = render(composer()) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste())) + first.unmount() + + // Back from the prompt card while the image is still on its way to the server. + render(composer()) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + + await act(async () => finishUpload(STORED)) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) + expect(mocks.state.fieldProps?.imageAttachments?.[0]?.pending).toBeFalsy() + }) + + it('holds Send for rich text while the server is still asked whether it takes the image', async () => { + const prepared = Promise.withResolvers() + mocks.prepare.mockReturnValue(prepared.promise) + holdUpload() + // What the user typed, and the pasted text with it: Send is open before the paste. + writeNativeChatDraftCache(PANE, 'look at this caption') + render(composer()) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(false) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption'))) + + // The text is in; the image is not yet anywhere, but it is owed to this message. + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + }) + + it("shows a rich-text paste's image once the server answers, even in a composer that came back", async () => { + const prepared = Promise.withResolvers() + mocks.prepare.mockReturnValue(prepared.promise) + const finishUpload = holdUpload() + writeNativeChatDraftCache(PANE, 'look at this caption') + const first = render(composer()) + await act(async () => mocks.state.fieldProps?.onPaste?.(imagePaste('caption'))) + // A prompt card replaces the composer while the server is still being asked. + first.unmount() + render(composer()) + expect(mocks.state.fieldProps?.sendButtonDisabled).toBe(true) + + await act(async () => + prepared.resolve({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 1, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + ) + // Shown, so the user can see it and remove it while it uploads. + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ pending: true }]) + expect(mocks.state.fieldProps?.imageAttachments?.[0]).not.toHaveProperty('hidden') + + await act(async () => finishUpload(STORED)) + expect(mocks.state.fieldProps?.imageAttachments).toMatchObject([{ path: STORED }]) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index a915e70327d1..871eb6f5a13b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -123,7 +123,8 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), flushPendingAttachments: mocks.flushPendingAttachments, - removeImageAttachment: vi.fn() + removeImageAttachment: vi.fn(), + pendingChips: { begin: vi.fn(), resolve: vi.fn(), drop: vi.fn(), attachReferences: vi.fn() } } } })) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index dac43fa42c12..a895159bba8f 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -175,10 +175,7 @@ const NativeChatComposerPane = forwardRef !attachment.hidden) const draftNotSaved = useNativeChatComposerDraftUnsaved(draftScopeKey) const optionCount = autocomplete.mode === 'slash' @@ -278,12 +286,13 @@ export function NativeChatComposerField({ '[contain:paint]' )} > - {imageAttachments.length > 0 ? ( + {shownAttachments.length > 0 ? (
- {imageAttachments.map((attachment) => ( + {shownAttachments.map((attachment) => ( ))} diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx index 286365db2176..b8d9400235e8 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.test.tsx @@ -24,9 +24,18 @@ afterEach(() => { mocks.useLocalImageSrc.mockReset() }) -function renderPreview(attachment: NativeChatComposerImageAttachment): void { +function renderPreview( + attachment: NativeChatComposerImageAttachment, + hostEnvironmentId?: string +): void { vi.stubGlobal('IntersectionObserver', undefined) - render() + render( + + ) } async function clickOn(target: Element): Promise { @@ -65,6 +74,58 @@ describe('NativeChatImageAttachmentPreview', () => { }) }) + // The path names a file on the paired server; this machine's disk must never be asked for it. + it('reads a stored chip back through the server that holds it', () => { + mocks.useLocalImageSrc.mockReturnValue('blob:from-server') + const path = '/srv/agent-session-attachments/u1/shot.png' + renderPreview({ id: 'a1', path }, 'env-1') + + for (const call of mocks.useLocalImageSrc.mock.calls) { + expect(call[3]).toEqual({ + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: null, + worktreePath: null + }) + } + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith(path, path, undefined, expect.anything(), { + kind: 'chat-image' + }) + expect(screen.getByRole('img', { name: 'shot.png' }).getAttribute('src')).toBe( + 'blob:from-server' + ) + }) + + it("keeps any other path on the chat's usual read route", () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '/repo/docs/shot.png' }, 'env-1') + + expect(mocks.useLocalImageSrc).toHaveBeenCalledWith( + '/repo/docs/shot.png', + '/repo/docs/shot.png', + undefined, + undefined, + { kind: 'chat-image' } + ) + }) + + it('shows a dropped file by name and kind while it uploads', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '', pending: true, pendingName: 'report.pdf' }) + + expect(screen.getByRole('button', { name: 'Uploading report.pdf…' })).toBeTruthy() + expect(screen.getByText('report.pdf')).toBeTruthy() + expect(document.querySelector('.lucide-file-text')).toBeTruthy() + expect(document.querySelector('.lucide-image')).toBeFalsy() + }) + + it('shows a dropped image as an image while it uploads', () => { + mocks.useLocalImageSrc.mockReturnValue(undefined) + renderPreview({ id: 'a1', path: '', pending: true, pendingName: 'shot.png' }) + + expect(screen.getByText('shot.png')).toBeTruthy() + expect(document.querySelector('.lucide-image')).toBeTruthy() + }) + it('offers the full-size file, not the clipboard thumbnail, to the chat copy menu', () => { mocks.useLocalImageSrc.mockReturnValue('blob:on-disk-1') renderPreview({ id: 'a1', path: '/tmp/example.png', previewUrl: 'data:thumbnail' }) diff --git a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx index 0ef722cd3a84..a46a6c2eeddb 100644 --- a/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx +++ b/src/renderer/src/components/native-chat/NativeChatImageAttachmentPreview.tsx @@ -1,22 +1,29 @@ -import { useEffect, useRef, useState } from 'react' -import { Image as ImageIcon, ImageOff, Loader2, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { FileText, Image as ImageIcon, ImageOff, Loader2, X } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' import { translate } from '@/i18n/i18n' import { basename } from '@/lib/path' import { useLocalImageSrc } from '@/components/editor/useLocalImageSrc' import { copyableNativeChatImageSrc, keepPreviewOpenForChatMenu } from './native-chat-image-copy' -import { isNativeChatPastedImagePath } from './native-chat-image-paste' +import { + isNativeChatImageAttachmentPath, + isNativeChatPastedImagePath +} from './native-chat-image-paste' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { chatImageAccess } from '@/lib/local-file-access' type Props = { attachment: NativeChatComposerImageAttachment + /** The paired server the chat runs on; a file stored there is read back through it. */ + hostEnvironmentId?: string onRemove: (id: string) => void } /** Thumbnail for a pending image, with an in-app full-size preview on click. */ export function NativeChatImageAttachmentPreview({ attachment, + hostEnvironmentId, onRemove }: Props): React.JSX.Element { if (attachment.unavailableName !== undefined) { @@ -28,7 +35,13 @@ export function NativeChatImageAttachmentPreview({ /> ) } - return + return ( + + ) } function attachmentLabel(path: string): string { @@ -98,7 +111,11 @@ function NativeChatUnavailableImageChip({ ) } -function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.Element { +function NativeChatImageThumbnail({ + attachment, + hostEnvironmentId, + onRemove +}: Props): React.JSX.Element { const [isOpen, setIsOpen] = useState(false) const [isNearViewport, setIsNearViewport] = useState(false) const thumbnailRef = useRef(null) @@ -124,11 +141,27 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El return () => observer.disconnect() }, []) const isPending = attachment.pending === true + // A stored attachment is a path on the paired server, read back through that server. + const readEnvironmentId = + hostEnvironmentId && isAgentSessionAttachmentStorePath(attachment.path) + ? hostEnvironmentId + : undefined + const hostReadContext = useMemo( + () => + readEnvironmentId + ? { + settings: { activeRuntimeEnvironmentId: readEnvironmentId }, + worktreeId: null, + worktreePath: null + } + : undefined, + [readEnvironmentId] + ) const localSrc = useLocalImageSrc( !isPending && (isNearViewport || isOpen) ? attachment.path : undefined, attachment.path, attachment.connectionId, - undefined, + hostReadContext, // Why chat-image: a draft handed off from the host queue may carry paths a paired client chose. chatImageAccess() ) @@ -137,11 +170,16 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El const thumbnailSrc = attachment.previewUrl ?? localSrc const fullSizeSrc = localSrc ?? attachment.previewUrl const filename = attachmentLabel(attachment.path) - const pendingLabel = translate( - 'components.native-chat.composer.imageSaving', - 'Saving pasted image…' - ) - const label = isPending ? pendingLabel : filename + const pendingLabel = attachment.pendingName + ? translate('components.native-chat.composer.uploadingFile', 'Uploading {{name}}…', { + name: attachment.pendingName + }) + : translate('components.native-chat.composer.imageSaving', 'Saving pasted image…') + const label = isPending ? (attachment.pendingName ?? pendingLabel) : filename + // A dropped document uploads as a chip too, and becomes an `@` reference once stored. + const isFile = + attachment.pendingName !== undefined && !isNativeChatImageAttachmentPath(attachment.pendingName) + const KindIcon = isFile ? FileText : ImageIcon // The thumbnail may be the downscaled clipboard preview; copy only the file. const copySrc = copyableNativeChatImageSrc(localSrc) @@ -167,8 +205,15 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El alt={label} className={`size-full object-cover${isPending ? ' opacity-50' : ''}`} /> + ) : attachment.pendingName ? ( + + + + {attachment.pendingName} + + ) : ( - + )} {isPending ? ( diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 44f5e737f2a4..67029a9c0c7f 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -70,6 +70,10 @@ const SENTENCES: Record string> = { unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => translate('components.native-chat.writeNotice.recordFailed', COPY.recordFailed), + attachmentExpired: () => + translate('components.native-chat.writeNotice.attachmentExpired', COPY.attachmentExpired), + reattachFile: () => + translate('components.native-chat.writeNotice.reattachFile', COPY.reattachFile), conversationCleared: () => translate('components.native-chat.writeNotice.conversationCleared', COPY.conversationCleared), openCurrentConversation: () => diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index c477f343ca20..e5468ef2c62e 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -7,9 +7,12 @@ const mocks = vi.hoisted(() => ({ toastDismiss: vi.fn(), toastError: vi.fn(), toastMessage: vi.fn(), - resolveDroppedPathsForAgent: vi.fn() + resolveDroppedPathsForAgent: vi.fn(), + callRuntimeRpc: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) + vi.mock('sonner', () => ({ toast: { loading: mocks.toastLoading, @@ -20,14 +23,20 @@ vi.mock('sonner', () => ({ })) vi.mock('@/i18n/i18n', () => ({ - translate: (_key: string, fallback: string) => fallback + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => String(values?.[name])) })) import { + nativeChatAttachFailedNotice, + prepareNativeChatSessionAttachmentUpload, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, + resolveNativeChatRuntimeSessionAttachmentOwner, uploadNativeChatAttachmentPaths } from './native-chat-attachment-upload' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' +import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' function terminalTab(overrides: Partial = {}): TerminalTab { return { @@ -225,3 +234,78 @@ describe('uploadNativeChatAttachmentPaths', () => { expect(mocks.toastDismiss).toHaveBeenCalledWith('toast-1') }) }) + +describe('a structured chat on a paired server', () => { + const owner = { + kind: 'runtime-session' as const, + environmentId: 'env-1', + pairingRevision: 7, + sessionId: 'session-1' + } + + beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) + mocks.callRuntimeRpc.mockReset() + }) + + it('owns its attachments by the server it runs on and that pairing', () => { + expect( + resolveNativeChatRuntimeSessionAttachmentOwner({ + sessionId: 'session-1', + runtimeEnvironmentId: 'env-1' + }) + ).toEqual(owner) + expect( + resolveNativeChatRuntimeSessionAttachmentOwner({ + sessionId: 'session-1', + runtimeEnvironmentId: 'env-gone' + }) + ).toEqual({ kind: 'not-ready' }) + }) + + it('asks the server, pinned to the pairing, and uploads only where it keeps a store', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ + runtimeId: 'runtime-a', + capabilities: [AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY] + }) + await expect(prepareNativeChatSessionAttachmentUpload(owner)).resolves.toEqual({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'status.get', + undefined, + expect.objectContaining({ expectedEnvironmentPairingRevision: 7 }) + ) + }) + + it('tells the user to update an older server instead of uploading', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ runtimeId: 'runtime-a', capabilities: [] }) + await expect(prepareNativeChatSessionAttachmentUpload(owner)).resolves.toEqual({ + ok: false, + notice: + 'This needs a newer Orca on the computer running this chat. Update Orca there, then try again.' + }) + }) +}) + +describe('nativeChatAttachFailedNotice', () => { + it('ends the shared cause with one full stop, whatever script it is written in', () => { + expect(nativeChatAttachFailedNotice(['a.mov'], 'over the 50 MB limit')).toBe( + "Couldn't attach a.mov. over the 50 MB limit." + ) + expect(nativeChatAttachFailedNotice(['a'], 'サポートされていないファイル形式です。')).toBe( + "Couldn't attach a. サポートされていないファイル形式です。" + ) + expect(nativeChatAttachFailedNotice(['a'], '不支持的文件类型!')).toBe( + "Couldn't attach a. 不支持的文件类型!" + ) + expect(nativeChatAttachFailedNotice(['a', 'b'])).toBe("Couldn't attach a, b.") + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index ad09b01ead41..7d9e0dfc6cf0 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -1,13 +1,23 @@ -// SSH-aware resolution for composer attachments (STA-1465). The composer's +// Host-aware resolution for composer attachments (STA-1465). The composer's // attach surfaces (file drop, file picker, image paste) receive client-local // paths, but an SSH worktree's agent runs on the remote host — local paths must -// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). +// be uploaded first, exactly like terminal drops (docs/terminal-drop-ssh.md). A +// structured chat on a paired server uploads into that server's attachment store. import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { getConnectionIdFromState } from '@/lib/connection-context' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_WRITE_NOTICE_COPY } from '../../../../shared/agent-session-write-notice-copy' +import type { + AgentSessionAttachmentPathUploadResult, + AgentSessionAttachmentUploadTarget +} from '../../../../shared/agent-session-attachments' import type { AppState } from '@/store/types' import { reportTerminalDropUploadSkipsAndFailures } from '../terminal-pane/terminal-drop-upload-report' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' @@ -26,11 +36,21 @@ export type NativeChatSshAttachmentOwner = DirectSshMutationExpectation & { worktreePath: string } +/** A structured chat on a paired server: files upload into that server's attachment store, which + * checks every stored path a message names when it admits the message. */ +export type NativeChatRuntimeSessionAttachmentOwner = { + kind: 'runtime-session' + environmentId: string + pairingRevision: number + sessionId: string +} + export type NativeChatAttachmentOwner = | { kind: 'local' } | NativeChatSshAttachmentOwner - /** Runtime-owned (`remote:`) panes keep the composer's existing - * local-attachment block; runtime upload support is a separate seam. */ + | NativeChatRuntimeSessionAttachmentOwner + /** A terminal-backed chat on a paired server: its agent may run on that server's SSH or WSL + * host, which the server's store cannot reach, so it keeps refusing client files. */ | { kind: 'runtime' } /** Store not hydrated / worktree unknown. Callers must not attach local * paths in this window — the worktree may turn out to be remote, and the @@ -97,6 +117,30 @@ export function resolveNativeChatAttachmentOwnerForWorktree( } } +/** The chat behind a structured composer, as the attachment owner needs it. */ +export type NativeChatStructuredAttachmentSession = { + sessionId: string + /** Null: the chat runs on this machine. */ + runtimeEnvironmentId: string | null +} + +/** A structured chat on a paired server owns its attachments by where it runs, not by worktree. */ +export function resolveNativeChatRuntimeSessionAttachmentOwner(session: { + sessionId: string + runtimeEnvironmentId: string +}): NativeChatAttachmentOwner { + const pairingRevision = getRuntimeEnvironmentRevision(session.runtimeEnvironmentId) + if (pairingRevision === undefined) { + return { kind: 'not-ready' } + } + return { + kind: 'runtime-session', + environmentId: session.runtimeEnvironmentId, + pairingRevision, + sessionId: session.sessionId + } +} + export function nativeChatWorktreeNotReadyNotice(): string { return translate( 'components.native-chat.composer.worktreeNotReady', @@ -118,6 +162,22 @@ export function nativeChatAttachmentUnreadableNotice(): string { ) } +/** One notice for every file of a drop or pick that did not attach, by name, with the cause they + * share when there is one. */ +export function nativeChatAttachFailedNotice(names: readonly string[], cause = ''): string { + const files = names.join(', ') + const reason = cause.trim().replace(/[^.!?。!?]$/u, '$&.') + return reason + ? translate( + 'components.native-chat.composer.attachFailedBecause', + "Couldn't attach {{files}}. {{reason}}", + { files, reason } + ) + : translate('components.native-chat.composer.attachFailed', "Couldn't attach {{files}}.", { + files + }) +} + export function nativeChatTooManyAttachmentsNotice(): string { return translate( 'components.native-chat.composer.tooManyAttachments', @@ -126,12 +186,7 @@ export function nativeChatTooManyAttachmentsNotice(): string { ) } -export function nativeChatLocalAttachmentUnsupportedNotice(): string { - return translate( - 'components.native-chat.composer.localAttachmentUnsupported', - 'Local attachments are not available for remote sessions.' - ) -} +export { nativeChatLocalAttachmentUnsupportedNotice } from './native-chat-composer-target' /** * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH @@ -168,3 +223,51 @@ export async function uploadNativeChatAttachmentPaths( toast.dismiss(pending) } } + +/** + * Ask the chat's server, right before uploading, whether it keeps chat attachments, and which + * server process will receive the bytes. An older server gets the update notice instead of an + * upload: a path from this machine would mean nothing to its agent. + */ +export async function prepareNativeChatSessionAttachmentUpload( + owner: NativeChatRuntimeSessionAttachmentOwner +): Promise< + { ok: true; target: AgentSessionAttachmentUploadTarget } | { ok: false; notice: string } +> { + const status = await callRuntimeRpc( + { kind: 'environment', environmentId: owner.environmentId }, + 'status.get', + undefined, + { timeoutMs: 15_000, expectedEnvironmentPairingRevision: owner.pairingRevision } + ) + if (!status.capabilities?.includes(AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY)) { + // The same words as any write a server too old for it refuses. + return { + ok: false, + notice: translate( + 'components.native-chat.writeNotice.unsupported', + AGENT_SESSION_WRITE_NOTICE_COPY.unsupported + ) + } + } + return { + ok: true, + target: { + environmentId: owner.environmentId, + sessionId: owner.sessionId, + expectedEnvironmentPairingRevision: owner.pairingRevision, + expectedEnvironmentRuntimeId: status.runtimeId + } + } +} + +/** + * Upload client-local files into the chat's store on its paired server. The chips show progress, + * so nothing else does; the caller reports what did not attach. + */ +export function uploadNativeChatSessionAttachmentPaths( + paths: string[], + target: AgentSessionAttachmentUploadTarget +): Promise { + return window.api.fs.uploadPathsToAgentSessionAttachments({ ...target, paths }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts new file mode 100644 index 000000000000..d0fd3b9268de --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-clipboard-image-save.ts @@ -0,0 +1,89 @@ +import { translate } from '@/i18n/i18n' +import { setNativeChatPasteFailure } from './native-chat-composer-notice' +import { + nativeChatLocalAttachmentUnsupportedNotice, + prepareNativeChatSessionAttachmentUpload, + type NativeChatAttachmentOwner +} from './native-chat-attachment-upload' + +type ClipboardImageOwner = Extract< + NativeChatAttachmentOwner, + { kind: 'local' | 'ssh' | 'runtime-session' } +> + +/** Owners whose attachment path is a file this client can write right now. */ +export function ownerAcceptsClipboardImage( + owner: NativeChatAttachmentOwner +): owner is ClipboardImageOwner { + return owner.kind === 'local' || owner.kind === 'ssh' || owner.kind === 'runtime-session' +} + +/** Where the clipboard image is saved: this machine, the SSH host, or the chat's store on its + * paired server, after asking that server whether it keeps one. */ +async function clipboardImageSaveArgs( + owner: ClipboardImageOwner +): Promise< + | { ok: true; args: Parameters[0] } + | { ok: false; notice: string; cause: NativeChatClipboardImageFailureCause } +> { + if (owner.kind === 'local') { + return { ok: true, args: { forNativeChatDraft: true } } + } + if (owner.kind === 'ssh') { + return { ok: true, args: { connectionId: owner.connectionId } } + } + const prepared = await prepareNativeChatSessionAttachmentUpload(owner) + if (!prepared.ok) { + return { ...prepared, cause: 'serverTooOld' } + } + const { environmentId, ...agentSessionAttachment } = prepared.target + return { ok: true, args: { runtimeEnvironmentId: environmentId, agentSessionAttachment } } +} + +/** `serverTooOld`: the chat's paired server keeps no attachments, so it could never take one. */ +export type NativeChatClipboardImageFailureCause = 'serverTooOld' | 'failed' + +/** Save the clipboard image where the owner's agent can read it. Every failure is reported. */ +export async function saveNativeChatClipboardImage( + owner: NativeChatAttachmentOwner, + report: { + /** `errorText`: the failure's own words, kept apart from Orca's notice. */ + setNotice: ( + notice: string, + cause: NativeChatClipboardImageFailureCause, + errorText?: string + ) => void + /** The image has somewhere to go, now that any server it goes to has said it takes one. */ + ready?: () => void + } +): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> { + if (!ownerAcceptsClipboardImage(owner)) { + report.setNotice(nativeChatLocalAttachmentUnsupportedNotice(), 'failed') + return { status: 'failed' } + } + try { + // SSH panes save the image on the remote host (SFTP) so the attached + // path is readable by the remote agent, matching terminal image paste. + const target = await clipboardImageSaveArgs(owner) + if (!target.ok) { + report.setNotice(target.notice, target.cause) + return { status: 'failed' } + } + report.ready?.() + const tempPath = await window.api.ui.saveClipboardImageAsTempFile(target.args) + return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } + } catch (error) { + // A failed save must be visible: over SSH it fails whenever the + // connection drops, and a silent no-op reads as a broken paste. + setNativeChatPasteFailure( + (notice, errorText) => { + if (notice !== null) { + report.setNotice(notice, 'failed', errorText) + } + }, + error, + translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') + ) + return { status: 'failed' } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts index 37c7d0124f84..efa65db9abf3 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-image-check.ts @@ -1,8 +1,11 @@ // Which restored draft images are known to be gone. A paste in Orca's paste folder asks main -// whether it is still kept, which holds only for a file really inside that folder; any other image -// goes through the existing existence check (the workspace's read rules locally, the host over SSH). +// whether it is still kept, which holds only for a file really inside that folder; a file a paired +// server stored for the chat is that server's to check, which its claim at send does; any other +// image goes through the existing existence check (the workspace's read rules locally, the host +// over SSH). import { useEffect, useSyncExternalStore } from 'react' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import type { NativeChatComposerDraftImage } from './native-chat-composer-draft-storage' import { isKeptLocalPaste, @@ -37,7 +40,7 @@ export async function findMissingNativeChatComposerDraftImages( } await Promise.all( checkable - .filter((image) => !isKeptLocalPaste(image)) + .filter((image) => !isKeptLocalPaste(image) && !isAgentSessionAttachmentStorePath(image.path)) .map(async (image) => { try { const exists = await pathExists({ diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index adedcc4d856c..4e93db226d93 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -444,8 +444,12 @@ describe('native-chat composer draft lifecycle', () => { { scopeKey: 'tab-1:pane' } ) ) - expect(second.api?.attachments.imageAttachments.map(({ path }) => path)).toEqual([ - '/repo/x.png' + // The paste still on its way comes back pending, so Send waits for it. + expect( + second.api?.attachments.imageAttachments.map(({ path, pending }) => ({ path, pending })) + ).toEqual([ + { path: '/repo/x.png', pending: undefined }, + { path: '', pending: true } ]) await act(async () => second.api?.attachments.clearImageAttachments()) await unmount() @@ -560,6 +564,30 @@ describe('native-chat composer draft lifecycle', () => { ]) }) + it('leaves a restored image a paired server stored to that server to check', async () => { + // Not on this machine's disk, so a local check could only call it gone. + const pathExists = vi.fn(async () => false) + vi.stubGlobal('api', { fs: { pathExists } }) + const stored = { id: 'a', path: '/srv/orca/agent-session-attachments/0f6c/shot.png' } + storage.drafts.set('agent-session:s1', { text: 'see this', images: [stored], savedAt: 1 }) + const hooks = await loadHooks() + const seen: { api?: ComposerApi } = {} + try { + await mount( + createElement( + composer(hooks, (next) => (seen.api = next)), + { scopeKey: 'agent-session:s1' } + ) + ) + await act(async () => {}) + } finally { + vi.unstubAllGlobals() + } + + expect(pathExists).not.toHaveBeenCalled() + expect(seen.api?.attachments.imageAttachments).toEqual([stored]) + }) + it('shows a restored paste from Orca’s paste folder once main confirms it is kept, and marks the rest', async () => { const folder = '/Users/me/Library/Application Support/orca/native-chat-pastes' let answer: () => void = () => {} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts index cd4a11faf776..dc9e0a3d139e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-persistence.ts @@ -3,6 +3,7 @@ import { basename } from '@/lib/path' import { isNativeChatKeptPastePath, isNativeChatPastedImagePath } from './native-chat-image-paste' +import { isAgentSessionAttachmentStorePath } from '../../../../shared/agent-session-attachments' import { dirtyScopes, hasLocalChange, @@ -54,11 +55,15 @@ export function isKeptLocalPaste(image: NativeChatComposerDraftImage): boolean { } /** What storage keeps: not an unsaved launch-seed copy, and a paste outside Orca's paste folder - * (over SSH, or from before it) only by name. Null when nothing is left. */ + * (over SSH, or from before it) only by name. A paste a paired server stored for the chat stays + * whole: the server keeps it, and its claim at send refuses one it no longer has. Null when + * nothing is left. */ function savedForm(record: DraftRecord): StoredNativeChatComposerDraft | null { const { unsavedText, ...saved } = record const images = saved.images.map((image) => - isNativeChatPastedImagePath(image.path) && !isKeptLocalPaste(image) + isNativeChatPastedImagePath(image.path) && + !isKeptLocalPaste(image) && + !isAgentSessionAttachmentStorePath(image.path) ? unavailableNativeChatComposerDraftImage(image) : image ) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts index 93cb58ba7ba6..c25c67cd0343 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.test.ts @@ -376,6 +376,17 @@ describe('native-chat composer draft store', () => { ]) }) + it('saves a paste a paired server stored for the chat as the real image', async () => { + const stored = { + id: 'p-1', + path: '/srv/orca/agent-session-attachments/0f6c/orca-paste-1-0f.png' + } + modules.attachments.appendNativeChatAttachmentCache('agent-session:s1', [stored]) + + const reloaded = await reload() + expect(reloaded.attachments.readNativeChatAttachmentCache('agent-session:s1')).toEqual([stored]) + }) + it('puts a re-attached image in the place of the one to attach again', async () => { storage.drafts.set('tab-1:pane', { text: 'see', diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts index b8ca9d888b00..60dddf06d2f7 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-store.ts @@ -69,6 +69,8 @@ export type NativeChatComposerDraftChange = { images?: readonly NativeChatComposerDraftImage[] /** Text shown but never saved while the draft still holds exactly it. */ unsavedText?: string + /** A new draft's owner when its chat may no longer be open to name it: the one it was begun in. */ + owner?: NativeChatComposerDraftOwner } const EMPTY_DRAFT: DraftRecord = { text: '', images: [], savedAt: 0 } @@ -82,6 +84,13 @@ export function setNativeChatComposerDraftOwnerResolver( resolveOwner = resolver } +/** The owner a new draft in this scope would record now. */ +export function resolveNativeChatComposerDraftOwner( + scopeKey: string +): NativeChatComposerDraftOwner | undefined { + return resolveOwner?.(scopeKey) +} + /** Composers render from the store; this tells one that its pane's draft changed. */ export function subscribeToNativeChatComposerDraft( scopeKey: string, @@ -156,7 +165,7 @@ export function updateNativeChatComposerDraft( return } // Why stamped once: a conversation never moves to another workspace, so its owner stays true. - const owner = current.owner ?? resolveOwner?.(scopeKey) + const owner = current.owner ?? change.owner ?? resolveOwner?.(scopeKey) const record: DraftRecord = { text, ...(document ? { document } : {}), @@ -192,14 +201,20 @@ function withAddition( */ export function appendToNativeChatComposerDraft( scopeKey: string, - addition: NativeChatComposerDraftAddition + addition: NativeChatComposerDraftAddition, + owner?: NativeChatComposerDraftOwner ): boolean { const before = records.get(scopeKey) const current = readNativeChatComposerDraft(scopeKey) const { text, images } = withAddition(current, addition) updateNativeChatComposerDraft( scopeKey, - { text, images, ...(text === current.text ? {} : { document: undefined }) }, + { + text, + images, + ...(text === current.text ? {} : { document: undefined }), + ...(owner ? { owner } : {}) + }, 'immediate', (loaded) => withAddition(loaded, addition, { once: true }) ) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx index 73953f509a9e..bd87875d3031 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-drop-scope.test.tsx @@ -81,6 +81,7 @@ function ComposerProbe({ terminalTabId: pane, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, + pendingChips: attachments.pendingChips, setNotice }) useNativeChatFileAttachmentActions(pane, attachExternalPaths) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-target.ts b/src/renderer/src/components/native-chat/native-chat-composer-target.ts index 08d31a4b11a6..efa532c88a14 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-target.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-target.ts @@ -52,4 +52,11 @@ export function nativeChatComposerTargetIsRemote(ptyId: string | null): boolean return ptyId !== null && isRemoteRuntimePtyId(ptyId) } +export function nativeChatLocalAttachmentUnsupportedNotice(): string { + return translate( + 'components.native-chat.composer.localAttachmentUnsupported', + 'Local attachments are not available for remote sessions.' + ) +} + export { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' diff --git a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts index 5d24fc155cc6..a6d2dbd5c05f 100644 --- a/src/renderer/src/components/native-chat/native-chat-draft-cache.ts +++ b/src/renderer/src/components/native-chat/native-chat-draft-cache.ts @@ -12,6 +12,7 @@ import { readNativeChatComposerDraft, updateNativeChatComposerDraft } from './native-chat-composer-draft-store' +import { clearNativeChatPendingAttachmentsForTests } from './native-chat-pending-attachment-cache' export function readNativeChatDraftCache(scopeKey: string): string { return readNativeChatComposerDraft(scopeKey).text @@ -119,4 +120,5 @@ export function appendNativeChatAttachmentCache( export function clearNativeChatAttachmentCacheForTests(): void { clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() } diff --git a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx index 2b0fe6351877..a1e675872ce9 100644 --- a/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-external-attachments-destination.test.tsx @@ -68,6 +68,7 @@ function Probe({ worktreeId }: { worktreeId?: string }): React.JSX.Element { structuredWorktreeId: worktreeId, disabled: false, attachResolvedPaths: attachments.attachResolvedPaths, + pendingChips: attachments.pendingChips, setNotice }) api = { ...attachments, ...external } diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts new file mode 100644 index 000000000000..3dccd946f89b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + dropNativeChatPendingAttachmentsForTab, + dropNativeChatPendingAttachmentsOwnedBy, + nativeChatPendingAttachmentSnapshot, + revealNativeChatPendingAttachment, + settleNativeChatPendingAttachment, + subscribeToNativeChatPendingAttachments, + takeNativeChatPendingAttachment +} from './native-chat-pending-attachment-cache' +import { + clearNativeChatComposerDraftsForTests, + deleteNativeChatComposerDraftsOwnedBy, + readNativeChatComposerDraft, + setNativeChatComposerDraftOwnerResolver, + structuredAgentSessionDraftScopeKey +} from './native-chat-composer-draft-store' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' + +afterEach(() => { + clearNativeChatPendingAttachmentsForTests() + clearNativeChatComposerDraftsForTests() +}) + +const OWNER: NativeChatComposerDraftOwner = { workspaceId: 'wt-1', executionHostId: 'local' } + +function addPending(scopeKey: string, id = scopeKey): void { + addNativeChatPendingAttachment(scopeKey, { id, path: '', pending: true }) +} + +describe('the pane pending attachment cache', () => { + it('keeps a chip with no composer subscribed, so one a prompt unmounted can still settle', () => { + addNativeChatPendingAttachment('pane-a', { + id: 'a1', + path: '', + pending: true, + previewUrl: 'blob:preview' + }) + + // The composer keeps its own preview; the cache never holds one. + expect(nativeChatPendingAttachmentSnapshot('pane-a')).toEqual([ + { id: 'a1', path: '', pending: true } + ]) + expect(takeNativeChatPendingAttachment('pane-a', 'a1')).toEqual({ + id: 'a1', + path: '', + pending: true + }) + expect(nativeChatPendingAttachmentSnapshot('pane-a')).toEqual([]) + }) + + it('answers undefined for a chip the user already removed, so its file never attaches', () => { + addNativeChatPendingAttachment('pane-b', { id: 'b1', path: '', pending: true }) + takeNativeChatPendingAttachment('pane-b', 'b1') + + expect(takeNativeChatPendingAttachment('pane-b', 'b1')).toBeUndefined() + }) + + it('shows a hidden chip and tells every subscriber, with a new snapshot only on change', () => { + const listener = vi.fn() + const unsubscribe = subscribeToNativeChatPendingAttachments('pane-c', listener) + addNativeChatPendingAttachment('pane-c', { id: 'c1', path: '', pending: true, hidden: true }) + const hidden = nativeChatPendingAttachmentSnapshot('pane-c') + + expect(nativeChatPendingAttachmentSnapshot('pane-c')).toBe(hidden) + revealNativeChatPendingAttachment('pane-c', 'c1') + + expect(nativeChatPendingAttachmentSnapshot('pane-c')).toEqual([ + { id: 'c1', path: '', pending: true } + ]) + expect(listener).toHaveBeenCalledTimes(2) + unsubscribe() + }) + + it('settles into a draft owned by the workspace it began in, after its chat stopped naming one', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + let open = true + setNativeChatComposerDraftOwnerResolver((scopeKey) => + open && scopeKey === conversation ? OWNER : undefined + ) + addPending(conversation) + open = false + + expect(settleNativeChatPendingAttachment(conversation, conversation, '/store/a.png')).toBe(true) + expect(readNativeChatComposerDraft(conversation).images).toHaveLength(1) + deleteNativeChatComposerDraftsOwnedBy(OWNER) + expect(readNativeChatComposerDraft(conversation).images).toEqual([]) + }) + + it('drops the chips begun in a removed workspace on that host only', () => { + const resolved = new Map([ + ['tab-1:leaf', OWNER], + ['tab-2:leaf', { ...OWNER, executionHostId: 'ssh:box' }], + ['tab-3:leaf', { ...OWNER, workspaceId: 'wt-2' }] + ]) + setNativeChatComposerDraftOwnerResolver((scopeKey) => resolved.get(scopeKey)) + for (const scopeKey of [...resolved.keys(), 'tab-4:leaf']) { + addPending(scopeKey) + } + + dropNativeChatPendingAttachmentsOwnedBy(OWNER) + + expect( + ['tab-1:leaf', 'tab-2:leaf', 'tab-3:leaf', 'tab-4:leaf'].map( + (scopeKey) => nativeChatPendingAttachmentSnapshot(scopeKey).length + ) + ).toEqual([0, 1, 1, 1]) + expect(settleNativeChatPendingAttachment('tab-1:leaf', 'tab-1:leaf', '/store/a.png')).toBe( + false + ) + expect(readNativeChatComposerDraft('tab-1:leaf').images).toEqual([]) + }) + + it('drops the chips of every pane in a closed tab, never a conversation’s', () => { + const conversation = structuredAgentSessionDraftScopeKey('session-1') + for (const scopeKey of ['tab:1:leaf-a', 'tab:1:leaf-b', 'tab:10:leaf-a', conversation]) { + addPending(scopeKey) + } + + dropNativeChatPendingAttachmentsForTab('agent-session') + dropNativeChatPendingAttachmentsForTab('tab:1') + + expect( + ['tab:1:leaf-a', 'tab:1:leaf-b', 'tab:10:leaf-a', conversation].map( + (scopeKey) => nativeChatPendingAttachmentSnapshot(scopeKey).length + ) + ).toEqual([0, 0, 1, 1]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts new file mode 100644 index 000000000000..3d4e477d0cee --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pending-attachment-cache.ts @@ -0,0 +1,169 @@ +// A composer's chips still on their way (a save, an upload, a server's answer), per pane scope, +// owned here rather than by the composer. A prompt card unmounts the composer, so anything owed to +// the message must outlive it: the composer that comes back shows the same chips, Send waits for +// them, and each settles into the scope's draft whichever composer, if any, is showing. They are +// never saved with the draft, so a restored draft cannot bring back an upload as if it were +// attached. Every chip dies with its operation: settled or dropped when the save or upload settles +// (each bounded by its call timeout), removed by the user, or dropped with the drafts of a tab or +// workspace the user closed or removed, so none settles into a draft nothing could find or delete. + +import { useCallback, useSyncExternalStore } from 'react' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' +import type { NativeChatComposerDraftOwner } from './native-chat-composer-draft-storage' +import { + appendToNativeChatComposerDraft, + nativeChatDraftScopeTabId, + resolveNativeChatComposerDraftOwner, + structuredAgentSessionIdOfDraftScope +} from './native-chat-composer-draft-store' + +const EMPTY: readonly NativeChatComposerImageAttachment[] = Object.freeze([]) +const pendingCache = new Map() +// Why: a chip can outlive its tab, which is what names the draft's owner, so it is named at once. +const pendingOwners = new Map() +const listeners = new Map void>>() + +/** The scope's pending chips; the same array until they change, so a reader can subscribe to it. */ +export function nativeChatPendingAttachmentSnapshot( + scopeKey: string +): readonly NativeChatComposerImageAttachment[] { + return pendingCache.get(scopeKey) ?? EMPTY +} + +function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void { + if (next.length === 0) { + pendingCache.delete(scopeKey) + pendingOwners.delete(scopeKey) + } else { + pendingCache.set(scopeKey, next) + } + listeners.get(scopeKey)?.forEach((listener) => listener()) +} + +export function subscribeToNativeChatPendingAttachments( + scopeKey: string, + listener: () => void +): () => void { + const scoped = listeners.get(scopeKey) ?? new Set() + listeners.set(scopeKey, scoped) + scoped.add(listener) + return () => { + scoped.delete(listener) + if (scoped.size === 0 && listeners.get(scopeKey) === scoped) { + listeners.delete(scopeKey) + } + } +} + +/** The scope's pending chips, re-rendering when they change. */ +export function useNativeChatPendingAttachments( + scopeKey: string +): readonly NativeChatComposerImageAttachment[] { + const subscribe = useCallback( + (listener: () => void) => subscribeToNativeChatPendingAttachments(scopeKey, listener), + [scopeKey] + ) + return useSyncExternalStore(subscribe, () => nativeChatPendingAttachmentSnapshot(scopeKey)) +} + +/** Adds a chip still on its way. Preview URLs stay with the composer that minted them. */ +export function addNativeChatPendingAttachment( + scopeKey: string, + chip: NativeChatComposerImageAttachment +): void { + const { previewUrl: _previewUrl, ...pending } = chip + const owner = resolveNativeChatComposerDraftOwner(scopeKey) ?? pendingOwners.get(scopeKey) + if (owner) { + pendingOwners.set(scopeKey, owner) + } + writePending(scopeKey, [...nativeChatPendingAttachmentSnapshot(scopeKey), pending]) +} + +/** Removes a pending chip and returns it; undefined when the user already removed it. */ +export function takeNativeChatPendingAttachment( + scopeKey: string, + id: string +): NativeChatComposerImageAttachment | undefined { + const current = nativeChatPendingAttachmentSnapshot(scopeKey) + const taken = current.find((attachment) => attachment.id === id) + if (taken) { + writePending( + scopeKey, + current.filter((attachment) => attachment !== taken) + ) + } + return taken +} + +/** Settles a pending chip into the scope's draft, as an image the user attached. False when the + * user already removed it. */ +export function settleNativeChatPendingAttachment( + scopeKey: string, + id: string, + path: string, + connectionId?: string | null +): boolean { + const owner = pendingOwners.get(scopeKey) + if (!takeNativeChatPendingAttachment(scopeKey, id)) { + return false + } + appendToNativeChatComposerDraft( + scopeKey, + { images: [{ id, path, ...(connectionId ? { connectionId } : {}) }], fromUser: true }, + owner + ) + return true +} + +/** Shows a pending chip that was held out of sight, such as while a server was asked first. */ +export function revealNativeChatPendingAttachment(scopeKey: string, id: string): void { + const current = nativeChatPendingAttachmentSnapshot(scopeKey) + if (current.some((attachment) => attachment.id === id && attachment.hidden)) { + writePending( + scopeKey, + current.map((attachment) => { + const { hidden: _hidden, ...shown } = attachment + return attachment.id === id ? shown : attachment + }) + ) + } +} + +export function clearNativeChatPendingAttachments(scopeKey: string): void { + if (pendingCache.has(scopeKey)) { + writePending(scopeKey, []) + } +} + +function dropPendingWhere( + matches: (scopeKey: string, owner: NativeChatComposerDraftOwner | undefined) => boolean +): void { + for (const scopeKey of pendingCache.keys()) { + if (matches(scopeKey, pendingOwners.get(scopeKey))) { + writePending(scopeKey, []) + } + } +} + +/** The chips of every pane in a closed tab, matched as its drafts are; never a conversation's. */ +export function dropNativeChatPendingAttachmentsForTab(tabId: string): void { + dropPendingWhere( + (scopeKey) => + structuredAgentSessionIdOfDraftScope(scopeKey) === null && + nativeChatDraftScopeTabId(scopeKey) === tabId + ) +} + +/** The chips begun in a removed workspace, open and closed chats alike. */ +export function dropNativeChatPendingAttachmentsOwnedBy(owner: NativeChatComposerDraftOwner): void { + dropPendingWhere( + (_scopeKey, pendingOwner) => + pendingOwner?.workspaceId === owner.workspaceId && + pendingOwner.executionHostId === owner.executionHostId + ) +} + +export function clearNativeChatPendingAttachmentsForTests(): void { + pendingCache.clear() + pendingOwners.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts new file mode 100644 index 000000000000..0d655d8de8a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-attachment-drop.ts @@ -0,0 +1,122 @@ +import { basename } from '@/lib/path' +import { extractIpcErrorMessage } from '@/lib/ipc-error' +import { describeDropSkipReason } from '@/lib/drop-skip-reason-copy' +import { isNativeChatImageAttachmentPath } from './native-chat-image-paste' +import { + nativeChatAttachFailedNotice, + nativeChatAttachmentOwnerChangedNotice, + prepareNativeChatSessionAttachmentUpload, + uploadNativeChatSessionAttachmentPaths, + type NativeChatRuntimeSessionAttachmentOwner +} from './native-chat-attachment-upload' + +/** The composer's pending-chip controls, as an upload drives them. */ +export type NativeChatPendingAttachmentChips = { + begin: (previewUrl?: string, pendingName?: string) => string | null + resolve: (id: string, path: string, connectionId?: string | null) => void + /** Removes the chip; false when the user already removed it, so its file must not attach. */ + drop: (id: string) => boolean + /** Adds stored files as `@path` references to the scope's draft, which keeps them through an + * input-method composition or a remount. */ + attachReferences: (paths: string[]) => void +} + +/** + * Drop or pick files into a structured chat on a paired server: upload them into the chat's store + * there, then attach the stored paths. Each file shows as a pending chip with its name at once, and + * Send waits for pending chips, so a message never leaves without a file the user attached. A chip + * the user removes meanwhile attaches nothing. + */ +export async function attachNativeChatSessionAttachmentPaths(args: { + paths: string[] + owner: NativeChatRuntimeSessionAttachmentOwner + chips: NativeChatPendingAttachmentChips + /** The composer was disabled or torn down meanwhile. */ + isAbandoned: () => boolean + ownerStillCurrent: () => boolean + setNotice: (notice: string | null) => void +}): Promise { + const pending = args.paths.flatMap((path) => { + const chipId = args.chips.begin(undefined, basename(path)) + // No chip: the composer refused the attach and already said why. + return chipId ? [{ path, chipId }] : [] + }) + if (pending.length === 0) { + return + } + const dropAll = (): void => { + for (const { chipId } of pending) { + args.chips.drop(chipId) + } + } + let stored: Map + let reasons: Map + try { + const prepared = await prepareNativeChatSessionAttachmentUpload(args.owner) + if (!prepared.ok) { + dropAll() + args.setNotice(prepared.notice) + return + } + const result = await uploadNativeChatSessionAttachmentPaths( + pending.map(({ path }) => path), + prepared.target + ) + stored = new Map(result.uploaded.map(({ sourcePath, path }) => [sourcePath, path])) + reasons = new Map([ + ...result.skipped.map(({ sourcePath, reason }): [string, string] => [ + sourcePath, + describeDropSkipReason(reason) ?? reason + ]), + ...result.failed.map(({ sourcePath, reason }): [string, string] => [sourcePath, reason]) + ]) + } catch (error) { + // Every file failed for the same cause. + stored = new Map() + const cause = extractIpcErrorMessage(error, '') + reasons = new Map(pending.map(({ path }) => [path, cause])) + } + if (args.isAbandoned()) { + dropAll() + return + } + if (!args.ownerStillCurrent()) { + dropAll() + args.setNotice(nativeChatAttachmentOwnerChangedNotice()) + return + } + const notAttached: { name: string; reason: string }[] = [] + const references: string[] = [] + for (const { path, chipId } of pending) { + const storedPath = stored.get(path) + if (!storedPath) { + if (args.chips.drop(chipId)) { + notAttached.push({ name: basename(path), reason: reasons.get(path) ?? '' }) + } + continue + } + if (isNativeChatImageAttachmentPath(storedPath)) { + args.chips.resolve(chipId, storedPath, null) + continue + } + // Other files become `@path` references, as every attach does, unless their chip was removed. + if (args.chips.drop(chipId)) { + references.push(storedPath) + } + } + // Inserting at the caret clears the notice, so what failed is said after. + if (references.length > 0) { + args.chips.attachReferences(references) + } + if (notAttached.length > 0) { + // A cause every file shares is said once, after their names. + const [first] = notAttached + const shared = notAttached.every(({ reason }) => reason === first?.reason) ? first?.reason : '' + args.setNotice( + nativeChatAttachFailedNotice( + notAttached.map(({ name }) => name), + shared + ) + ) + } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 6523ff17fa63..036e6b746430 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -530,3 +530,47 @@ describe('an open composer', () => { ]) }) }) + +describe('a message the host refused for an attachment it no longer stores', () => { + const STORED = '/srv/agent-session-attachments/0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01/shot.png' + + function refuseSendsAsExpired(): void { + mocks.call.mockImplementation(async (_target, method) => + method === 'agentSession.send' + ? { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'not stored', + details: { reason: 'attachmentExpired' } + } + } + : null + ) + } + + it('comes back to the composer with its images, saying why, instead of waiting on a Retry', async () => { + refuseSendsAsExpired() + const { result } = renderOutbox() + act(() => + expect(result.current.send('look at this', [{ path: STORED, previewUri: STORED }])).toBe(true) + ) + + await waitFor(() => expect(result.current.outbox).toEqual([])) + expect(readOutbox(SESSION)).toEqual([]) + expect(readNativeChatDraftCache(PANE)).toBe('look at this') + expect(readNativeChatAttachmentCache(PANE)).toEqual([{ id: expect.any(String), path: STORED }]) + expect(result.current.error).toBe( + 'This attachment expired. Your message was not sent. Remove it and attach it again.' + ) + }) + + it('stays on its Retry where no composer shows the chat, so nothing is lost', async () => { + refuseSendsAsExpired() + const { result } = renderOutbox(null) + act(() => expect(result.current.send('look at this')).toBe(true)) + + await waitFor(() => expect(result.current.outbox[0]?.lastFailure).toBeDefined()) + expect(result.current.outbox).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 2bfa6a087373..d6498a38031f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -6,6 +6,22 @@ import { appendNativeChatAttachmentCache, readNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +import type { StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' +import type { AgentSessionWriteRefusal } from '../../../../shared/agent-session-write-failure' +import { agentSessionWriteNoticeParts } from '../../../../shared/agent-session-refusal-notice' + +/** The host refused the message for an attachment it no longer stores: sending the same message + * again can never succeed, so only the sender can fix it. */ +function attachmentExpiredRefusal( + entry: StructuredAgentSessionOutboxEntry +): AgentSessionWriteRefusal | null { + const failure = entry.lastFailure + return failure?.kind === 'refused' && + failure.code === 'agent_session_operation_invalid' && + failure.details?.reason === 'attachmentExpired' + ? failure + : null +} /** Puts a message's text and images into a composer, after whatever is there. */ export function returnMessageToComposer( @@ -62,9 +78,38 @@ export function useStructuredAgentSessionWithdrawnRestore( /** Entries a Stop took out of the outbox here, before the host held them; false when the * composer could not take them. */ byStop: (entries: readonly StructuredAgentSessionOutboxEntry[]) => boolean + /** A send's outcome, with any message refused for an expired attachment taken back out and + * returned to the composer, where the attachment can be removed, instead of held for a Retry + * that cannot succeed. Kept for Retry where no composer shows this session. */ + byRefusal: ( + disposition: StructuredAgentSessionSendDisposition + ) => StructuredAgentSessionSendDisposition } { return useMemo( - () => ({ byStop: (entries) => restoreUnsentMessages(composerScopeKey, entries) }), + () => ({ + byStop: (entries) => restoreUnsentMessages(composerScopeKey, entries), + byRefusal: (disposition) => { + const returned = disposition.entries.filter( + (entry) => attachmentExpiredRefusal(entry) !== null + ) + const refusal = returned[0] && attachmentExpiredRefusal(returned[0]) + if (!composerScopeKey || !refusal) { + return disposition + } + // Only the send's own view applies its outcome, so nothing else gives these back. + for (const entry of returned) { + returnMessageToComposer( + composerScopeKey, + `withdrawn-${entry.clientMessageId}`, + entry.body.blocks + ) + } + return { + entries: disposition.entries.filter((entry) => !returned.includes(entry)), + error: agentSessionWriteNoticeParts(refusal, 'send') + } + } + }), [composerScopeKey] ) } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts new file mode 100644 index 000000000000..02d18e2dc2ab --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachment-previews.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +const NO_PREVIEWS: ReadonlyMap = new Map() + +/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ +function releasePreviewUrl(previewUrl: string | undefined): void { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl) + } +} + +/** The clipboard previews a composer minted, by chip id: its own, since no store holds them. A + * preview whose chip left both the draft and the pending chips (sent, or removed elsewhere) is + * released. */ +export function useNativeChatComposerAttachmentPreviews( + settled: readonly NativeChatComposerImageAttachment[], + pending: readonly NativeChatComposerImageAttachment[] +): { + previews: ReadonlyMap + setPreview: (id: string, previewUrl: string | undefined) => void + releasePreview: (id: string) => void + releaseAllPreviews: () => void +} { + const [previews, setPreviews] = useState>(NO_PREVIEWS) + // Read by callbacks between renders; only they change it, always together with the state. + const previewsRef = useRef(previews) + const updatePreviews = useCallback((next: ReadonlyMap) => { + previewsRef.current = next + setPreviews(next) + }, []) + useEffect(() => { + const current = previewsRef.current + const gone = [...current.keys()].filter( + (id) => !settled.some((image) => image.id === id) && !pending.some((chip) => chip.id === id) + ) + if (gone.length === 0) { + return + } + const next = new Map(current) + for (const id of gone) { + releasePreviewUrl(next.get(id)) + next.delete(id) + } + updatePreviews(next) + }, [pending, settled, updatePreviews]) + + const setPreview = useCallback( + (id: string, previewUrl: string | undefined) => { + if (previewUrl) { + updatePreviews(new Map(previewsRef.current).set(id, previewUrl)) + } + }, + [updatePreviews] + ) + + const releasePreview = useCallback( + (id: string) => { + const current = previewsRef.current + if (!current.has(id)) { + return + } + releasePreviewUrl(current.get(id)) + const next = new Map(current) + next.delete(id) + updatePreviews(next) + }, + [updatePreviews] + ) + + const releaseAllPreviews = useCallback(() => { + previewsRef.current.forEach(releasePreviewUrl) + updatePreviews(NO_PREVIEWS) + }, [updatePreviews]) + + return { previews, setPreview, releasePreview, releaseAllPreviews } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index ecb90b5d0568..5208dfe6e8ac 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -10,6 +10,8 @@ import { } from './use-native-chat-composer-attachments' import * as draftCache from './native-chat-draft-cache' import type { NativeChatResolvedTarget } from './native-chat-composer-target' +import { nativeChatPendingAttachmentSnapshot } from './native-chat-pending-attachment-cache' +import { readNativeChatDraftCache } from './native-chat-draft-cache' import { NATIVE_FILE_DROP_MAX_PATHS } from '../../../../shared/native-file-drop' vi.mock('@/i18n/i18n', () => ({ @@ -450,7 +452,122 @@ describe('useNativeChatComposerAttachments', () => { act(() => probe.root.unmount()) }) - it('excludes a pending chip from the scope cache while a settled chip persists', async () => { + it('tells a finishing upload that the user removed its chip', async () => { + const probe = await renderProbe('pty-1') + const begun: { removed?: string | null; kept?: string | null } = {} + act(() => { + begun.removed = probe.latest().pendingChips.begin(undefined, 'report.pdf') + begun.kept = probe.latest().pendingChips.begin(undefined, 'notes.md') + }) + const { removed, kept } = begun + if (!removed || !kept) { + throw new Error('expected pending chips') + } + act(() => probe.latest().removeImageAttachment(removed)) + + const live: boolean[] = [] + act(() => { + live.push(probe.latest().pendingChips.drop(removed), probe.latest().pendingChips.drop(kept)) + }) + expect(live).toEqual([false, true]) + expect(probe.latest().imageAttachments).toEqual([]) + act(() => probe.root.unmount()) + }) + + it('keeps an upload that finishes while the composer is unmounted, for when it returns', async () => { + const probe = await renderProbe('pty-gone') + const chips = probe.latest().pendingChips + const begun: { image?: string | null; removed?: string | null } = {} + act(() => { + begun.image = chips.begin(undefined, 'shot.png') + begun.removed = chips.begin(undefined, 'old.png') + }) + const { image, removed } = begun + if (!image || !removed) { + throw new Error('expected pending chips') + } + act(() => probe.latest().removeImageAttachment(removed)) + // A prompt card took the composer's place while the files uploaded. + act(() => probe.root.unmount()) + + chips.resolve(image, '/srv/agent-session-attachments/u1/shot.png') + chips.resolve(removed, '/srv/agent-session-attachments/u2/old.png') + chips.attachReferences(['/srv/agent-session-attachments/u3/notes.pdf']) + + expect(readNativeChatAttachmentCache('pty-gone')).toEqual([ + { id: image, path: '/srv/agent-session-attachments/u1/shot.png' } + ]) + expect(readNativeChatDraftCache('pty-gone')).toBe( + '@/srv/agent-session-attachments/u3/notes.pdf' + ) + const back = await renderProbe('pty-gone') + expect(back.latest().imageAttachments).toMatchObject([ + { path: '/srv/agent-session-attachments/u1/shot.png' } + ]) + act(() => back.root.unmount()) + }) + + it('shows a dropped file still uploading as pending to a composer that comes back, then settles it there', async () => { + const first = await renderProbe('pty-drop') + const chips = first.latest().pendingChips + let chipId: string | null = null + act(() => { + chipId = chips.begin(undefined, 'shot.png') + }) + if (!chipId) { + throw new Error('expected a pending chip') + } + const id: string = chipId + act(() => first.root.unmount()) + + const back = await renderProbe('pty-drop') + expect(back.latest().imageAttachments).toMatchObject([ + { id, pending: true, pendingName: 'shot.png' } + ]) + act(() => chips.resolve(id, '/srv/agent-session-attachments/u5/shot.png')) + expect(back.latest().imageAttachments).toEqual([ + { id, path: '/srv/agent-session-attachments/u5/shot.png' } + ]) + act(() => back.root.unmount()) + }) + + it('inserts a stored file at the caret while the composer is showing and not composing', async () => { + const probe = await renderProbe('pty-caret', true) + act(() => + probe.latest().pendingChips.attachReferences(['/srv/agent-session-attachments/u6/a.pdf']) + ) + expect(probe.draft()).toBe('@/srv/agent-session-attachments/u6/a.pdf ') + expect(readNativeChatDraftCache('pty-caret')).toBe('') + act(() => probe.root.unmount()) + }) + + it('keeps a stored file whose reference is held for an input-method composition across a remount', async () => { + let composing = true + const probe = await renderProbe('pty-ime', true, { isComposing: () => composing }) + const chips = probe.latest().pendingChips + let chipId: string | null = null + act(() => { + chipId = chips.begin(undefined, 'notes.pdf') + }) + if (!chipId) { + throw new Error('expected a pending chip') + } + const id: string = chipId + // The upload finishes mid-composition: the reference waits for the composition to settle. + act(() => { + expect(chips.drop(id)).toBe(true) + chips.attachReferences(['/srv/agent-session-attachments/u4/notes.pdf']) + }) + // A prompt card takes the composer's place before the composition settles. + act(() => probe.root.unmount()) + composing = false + + expect(readNativeChatDraftCache('pty-ime')).toContain( + '@/srv/agent-session-attachments/u4/notes.pdf' + ) + }) + + it('keeps a pending chip in the pending cache, without its preview, and the settled one in the draft', async () => { const probe = await renderProbe('pty-1') let pendingId: string | null = null act(() => { @@ -460,10 +577,14 @@ describe('useNativeChatComposerAttachments', () => { probe.latest().attachResolvedPaths(['/tmp/settled.png']) }) - const cached = readNativeChatAttachmentCache('pty-1') - expect(cached.some((attachment) => attachment.id === pendingId)).toBe(false) - expect(cached).toMatchObject([{ path: '/tmp/settled.png' }]) - expect(cached[0]?.previewUrl).toBeUndefined() + // A composer that comes back must still wait for the pending one, which no draft saves. + expect(nativeChatPendingAttachmentSnapshot('pty-1')).toEqual([ + { id: pendingId, path: '', pending: true } + ]) + expect(readNativeChatAttachmentCache('pty-1')).toMatchObject([{ path: '/tmp/settled.png' }]) + expect( + probe.latest().imageAttachments.find((attachment) => attachment.id === pendingId)?.previewUrl + ).toBe('blob:preview-1') act(() => probe.root.unmount()) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index caec3b8118f8..b3fe6e13bf24 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -1,17 +1,15 @@ import type { NativeChatComposerInput } from './native-chat-composer-input' -import { appendNativeChatAttachmentCache } from './native-chat-draft-cache' import { useCallback, useEffect, useMemo, useRef, - useState, useSyncExternalStore, type RefObject } from 'react' -import { translate } from '@/i18n/i18n' import { nativeChatComposerTargetIsRemote, + nativeChatLocalAttachmentUnsupportedNotice, type NativeChatResolvedTarget } from './native-chat-composer-target' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' @@ -24,6 +22,18 @@ import { import { useRestoredNativeChatComposerDraftImageCheck } from './native-chat-composer-draft-image-check' import type { NativeChatResolvedPathOptions } from './native-chat-resolved-path-ownership' import { useNativeChatResolvedPathAttachments } from './use-native-chat-resolved-path-attachments' +import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachments, + revealNativeChatPendingAttachment, + settleNativeChatPendingAttachment, + takeNativeChatPendingAttachment, + useNativeChatPendingAttachments +} from './native-chat-pending-attachment-cache' +import { appendNativeChatAttachmentCache, appendNativeChatDraftCache } from './native-chat-draft-cache' +import { useNativeChatComposerAttachmentPreviews } from './use-native-chat-composer-attachment-previews' +import { formatNativeChatFileReference } from '../../../../shared/agent-image-paste' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string @@ -62,9 +72,15 @@ export function useNativeChatComposerAttachments({ clearImageAttachments: () => void flushPendingAttachments: () => void removeImageAttachment: (id: string) => void - beginPendingImageAttachment: (previewUrl?: string) => string | null + beginPendingImageAttachment: ( + previewUrl?: string, + pendingName?: string, + options?: { hidden?: true } + ) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void - dropPendingImageAttachment: (id: string) => void + revealPendingImageAttachment: (id: string, previewUrl?: string) => void + dropPendingImageAttachment: (id: string) => boolean + pendingChips: NativeChatPendingAttachmentChips } { const subscribe = useCallback( (listener: () => void) => subscribeToNativeChatComposerDraft(attachmentScopeKey, listener), @@ -77,44 +93,35 @@ export function useNativeChatComposerAttachments({ // Why: a restored paste shows only once main confirms it is still kept, so until the restore // check is done it waits like a chip still saving, instead of flashing before a placeholder. const restoring = useRestoredNativeChatComposerDraftImageCheck(attachmentScopeKey, subscribe) - // Chips still being written, and the clipboard previews this composer minted, are its own. - const [local, setLocal] = useState(NO_LOCAL_ATTACHMENTS) - // Read by callbacks between renders; only they change it, always together with the state. - const localRef = useRef(local) - const updateLocal = useCallback((next: LocalAttachments) => { - localRef.current = next - setLocal(next) - }, []) + // Chips still on their way live outside the composer, so one a prompt card unmounted comes back + // still pending and settles into the draft whichever composer is showing. + const pending = useNativeChatPendingAttachments(attachmentScopeKey) + const { previews, setPreview, releasePreview, releaseAllPreviews } = + useNativeChatComposerAttachmentPreviews(settled, pending) const imageAttachments = useMemo( () => [ ...settled.map((image) => { - const previewUrl = local.previews.get(image.id) + const previewUrl = previews.get(image.id) if (restoring && isKeptLocalPaste(image)) { return { ...image, pending: true } } return previewUrl ? { ...image, previewUrl } : image }), - ...local.pending + ...pending.map((chip) => { + const previewUrl = previews.get(chip.id) + return previewUrl ? { ...chip, previewUrl } : chip + }) ], - [local, restoring, settled] + [pending, previews, restoring, settled] ) - // A preview whose image left the draft (sent, or removed elsewhere) is released. + const imageAttachmentCounter = useRef(0) + const mountedRef = useRef(true) useEffect(() => { - const current = localRef.current - const gone = [...current.previews.keys()].filter( - (id) => !settled.some((image) => image.id === id) - ) - if (gone.length === 0) { - return - } - const previews = new Map(current.previews) - for (const id of gone) { - releasePreviewUrl(previews.get(id)) - previews.delete(id) + mountedRef.current = true + return () => { + mountedRef.current = false } - updateLocal({ ...current, previews }) - }, [settled, updateLocal]) - const imageAttachmentCounter = useRef(0) + }, []) const nextAttachmentId = useCallback((): string => { imageAttachmentCounter.current += 1 @@ -135,12 +142,7 @@ export function useNativeChatComposerAttachments({ ) const noteAttachmentTargetBlocked = useCallback(() => { - setNotice( - translate( - 'components.native-chat.composer.localAttachmentUnsupported', - 'Local attachments are not available for remote sessions.' - ) - ) + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) }, [setNotice]) const appendImageAttachments = useCallback( @@ -176,9 +178,10 @@ export function useNativeChatComposerAttachments({ // Placeholder chip shown the instant a paste starts, so a clipboard image that // takes a beat to save (or upload over SSH) never reads as a dropped paste. const beginPendingImageAttachment = useCallback( - (previewUrl?: string): string | null => { - // Without image input the saved paste is attached by path once it lands, so no image chip. - if (disabledRef.current || !acceptsImages) { + (previewUrl?: string, pendingName?: string, options?: { hidden?: true }): string | null => { + // Without image input the saved paste is attached by path once it lands, so no image chip; + // a named upload chip is a file, not an image, and still shows. + if (disabledRef.current || (!acceptsImages && pendingName === undefined)) { return null } if (attachmentTargetBlocked()) { @@ -186,83 +189,94 @@ export function useNativeChatComposerAttachments({ return null } const id = nextAttachmentId() - const current = localRef.current - updateLocal({ - ...current, - pending: [...current.pending, { id, path: '', previewUrl, pending: true }] + addNativeChatPendingAttachment(attachmentScopeKey, { + id, + path: '', + pending: true, + ...(pendingName ? { pendingName } : {}), + ...(options?.hidden ? { hidden: true } : {}) }) + setPreview(id, previewUrl) return id }, [ acceptsImages, + attachmentScopeKey, attachmentTargetBlocked, disabledRef, nextAttachmentId, noteAttachmentTargetBlocked, - updateLocal + setPreview ] ) - /** Drops this composer's own copy of a chip; the store is not touched. */ - const forgetLocalAttachment = useCallback( - (id: string, keepPreview = false): NativeChatComposerImageAttachment | undefined => { - const current = localRef.current - const pending = current.pending.find((attachment) => attachment.id === id) - const previewUrl = pending?.previewUrl ?? current.previews.get(id) - if (!pending && previewUrl === undefined) { - return undefined - } - const previews = new Map(current.previews) - previews.delete(id) - if (keepPreview && previewUrl) { - previews.set(id, previewUrl) - } else { - releasePreviewUrl(previewUrl) - } - updateLocal({ - pending: current.pending.filter((attachment) => attachment.id !== id), - previews - }) - return pending + const resolvePendingImageAttachment = useCallback( + (id: string, path: string, connectionId?: string | null) => { + settleNativeChatPendingAttachment(attachmentScopeKey, id, path, connectionId) }, - [updateLocal] + [attachmentScopeKey] ) - const resolvePendingImageAttachment = useCallback( - (id: string, path: string, connectionId?: string | null) => { - if (forgetLocalAttachment(id, true)) { - appendNativeChatAttachmentCache( - attachmentScopeKey, - [{ id, path, ...(connectionId ? { connectionId } : {}) }], - { fromUser: true } - ) - } + const revealPendingImageAttachment = useCallback( + (id: string, previewUrl?: string) => { + setPreview(id, previewUrl) + revealNativeChatPendingAttachment(attachmentScopeKey, id) }, - [attachmentScopeKey, forgetLocalAttachment] + [attachmentScopeKey, setPreview] ) // A pending chip was never saved, so dropping one, even late from a replaced composer, leaves - // the store alone. + // the draft alone. const dropPendingImageAttachment = useCallback( - (id: string) => { - forgetLocalAttachment(id) + (id: string): boolean => { + releasePreview(id) + return takeNativeChatPendingAttachment(attachmentScopeKey, id) !== undefined }, - [forgetLocalAttachment] + [attachmentScopeKey, releasePreview] + ) + + const pendingChips = useMemo( + (): NativeChatPendingAttachmentChips => ({ + begin: beginPendingImageAttachment, + resolve: resolvePendingImageAttachment, + drop: dropPendingImageAttachment, + // At the caret, as every attach does; mid-composition or once the composer is gone, into the + // scope's draft, which keeps it until the composition settles or the composer comes back. + attachReferences: (paths) => { + if (mountedRef.current && !isComposing()) { + attachResolvedPaths(paths, null) + return + } + appendNativeChatDraftCache( + attachmentScopeKey, + paths.map(formatNativeChatFileReference).join(' ') + ) + } + }), + [ + attachResolvedPaths, + attachmentScopeKey, + beginPendingImageAttachment, + dropPendingImageAttachment, + isComposing, + resolvePendingImageAttachment + ] ) return { + pendingChips, imageAttachments, attachResolvedPaths, clearImageAttachments: () => { - const current = localRef.current - current.pending.forEach((attachment) => releasePreviewUrl(attachment.previewUrl)) - current.previews.forEach(releasePreviewUrl) - updateLocal(NO_LOCAL_ATTACHMENTS) + releaseAllPreviews() + clearNativeChatPendingAttachments(attachmentScopeKey) updateNativeChatComposerDraft(attachmentScopeKey, { images: [] }, 'immediate') }, flushPendingAttachments, removeImageAttachment: (id) => { - if (forgetLocalAttachment(id)) { + releasePreview(id) + // A chip still on its way is removed before it settles, so its file never joins the message. + if (takeNativeChatPendingAttachment(attachmentScopeKey, id)) { return } const images = readNativeChatComposerDraft(attachmentScopeKey).images @@ -274,24 +288,11 @@ export function useNativeChatComposerAttachments({ }, beginPendingImageAttachment, resolvePendingImageAttachment, + revealPendingImageAttachment, dropPendingImageAttachment } } -type LocalAttachments = { - pending: NativeChatComposerImageAttachment[] - previews: ReadonlyMap -} - -const NO_LOCAL_ATTACHMENTS: LocalAttachments = { pending: [], previews: new Map() } - -/** Object URLs minted from a clipboard blob leak until revoked; data URLs don't. */ -function releasePreviewUrl(previewUrl: string | undefined): void { - if (previewUrl?.startsWith('blob:')) { - URL.revokeObjectURL(previewUrl) - } -} - export { readNativeChatAttachmentCache, appendNativeChatAttachmentCache, diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx index 661669ab6775..93e289e44625 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-image-input.test.tsx @@ -93,4 +93,29 @@ describe('structured composer image input', () => { expect(result.pendingChip).toBeNull() expect(result.draft).toContain('/tmp/shot.png') }) + + it('still shows a named upload chip when the agent takes no images', async () => { + const container = document.createElement('div') + document.body.append(container) + let api: AttachmentApi | null = null + const root = createRoot(container) + await act(async () => { + root.render( + createElement(Probe, { + acceptsImages: false, + onReady: (next) => { + api = next + } + }) + ) + }) + const latest = (): AttachmentApi => { + if (!api) { + throw new Error('Probe did not render') + } + return api + } + expect(latest().beginPendingImageAttachment(undefined, 'notes.txt')).not.toBeNull() + act(() => root.unmount()) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx new file mode 100644 index 000000000000..421462ae1447 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste-session-attachments.test.tsx @@ -0,0 +1,303 @@ +// @vitest-environment happy-dom +// Pastes into a structured chat on a paired server land in that server's attachment store, on +// every paste path; the server checks each stored path again when it admits the message. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +const mocks = vi.hoisted(() => ({ + saveClipboardImageAsTempFile: vi.fn(), + readClipboardText: vi.fn(), + readClipboardImageThumbnail: vi.fn(), + clipboardHasImage: vi.fn(), + readClipboardFilePaths: vi.fn(), + prepareNativeChatSessionAttachmentUpload: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('./native-chat-composer-target', () => ({ + NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES: 1024 +})) + +vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', + nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.', + prepareNativeChatSessionAttachmentUpload: mocks.prepareNativeChatSessionAttachmentUpload +})) + +vi.stubGlobal('window', { + api: { + ui: { + saveClipboardImageAsTempFile: mocks.saveClipboardImageAsTempFile, + readClipboardText: mocks.readClipboardText, + readClipboardImageThumbnail: mocks.readClipboardImageThumbnail, + clipboardHasImage: mocks.clipboardHasImage, + readClipboardFilePaths: mocks.readClipboardFilePaths + } + } +}) +vi.stubGlobal('URL', { + createObjectURL: () => 'blob:clipboard-image', + revokeObjectURL: () => {} +}) + +import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' + +type HookApi = ReturnType +type Chip = { id: string; path: string; pending: boolean; hidden?: true } + +const sessionOwner: NativeChatAttachmentOwner = { + kind: 'runtime-session', + environmentId: 'env-1', + pairingRevision: 7, + sessionId: 'session-1' +} +const storeArgs = { + runtimeEnvironmentId: 'env-1', + agentSessionAttachment: { + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } +} +const storedPath = '/srv/agent-session-attachments/u1/orca-paste-1.png' + +let root: Root | null = null + +async function renderPaste(args: { + attachResolvedPaths?: (...args: unknown[]) => void + insertTypedText?: (text: string) => boolean + setNotice?: (notice: string | null) => void +}): Promise<{ api: () => HookApi; chips: Chip[]; begun: () => number; revealed: () => number }> { + const chips: Chip[] = [] + let counter = 0 + let revealed = 0 + let api: HookApi | null = null + function Probe(): null { + api = useNativeChatComposerPaste({ + targetKey: 'session-1', + agent: 'claude', + disabled: false, + caret: 0, + setCaret: () => {}, + resolveAttachmentOwner: () => sessionOwner, + attachResolvedPaths: args.attachResolvedPaths ?? (() => {}), + beginPendingImageAttachment: (_preview, _name, options) => { + counter += 1 + chips.push({ + id: `chip-${counter}`, + path: '', + pending: true, + ...(options?.hidden ? { hidden: true } : {}) + }) + return `chip-${counter}` + }, + revealPendingImageAttachment: (id) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + delete chip.hidden + revealed += 1 + } + }, + resolvePendingImageAttachment: (id, path) => { + const chip = chips.find((candidate) => candidate.id === id) + if (chip) { + Object.assign(chip, { path, pending: false }) + } + }, + dropPendingImageAttachment: (id) => { + chips.splice( + chips.findIndex((candidate) => candidate.id === id), + 1 + ) + }, + insertTypedText: args.insertTypedText ?? (() => true), + setNotice: args.setNotice ?? (() => {}) + }) + return null + } + const container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root?.render(createElement(Probe))) + return { + api: () => { + if (!api) { + throw new Error('Probe did not render') + } + return api + }, + chips, + begun: () => counter, + revealed: () => revealed + } +} + +function imagePasteEvent(text = ''): ClipboardEvent { + const data = new DataTransfer() + data.items.add(new File(['image'], 'image.png', { type: 'image/png' })) + if (text) { + data.setData('text/plain', text) + } + return new ClipboardEvent('paste', { clipboardData: data, cancelable: true }) +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.readClipboardText.mockResolvedValue('') + mocks.readClipboardImageThumbnail.mockResolvedValue(null) + mocks.clipboardHasImage.mockResolvedValue(false) + mocks.readClipboardFilePaths.mockResolvedValue([]) + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: true, + target: { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + }) + mocks.saveClipboardImageAsTempFile.mockResolvedValue(storedPath) +}) + +afterEach(() => { + root?.unmount() + root = null +}) + +describe('pasting into a structured chat on a paired server', () => { + it('saves a pasted image into the chat store and settles the chip with its server path', async () => { + const probe = await renderPaste({}) + await act(async () => probe.api().handlePaste(imagePasteEvent())) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) + }) + + it('keeps the image of a paste that also carries text, its chip shown once the server takes it', async () => { + const insertTypedText = vi.fn(() => true) + const probe = await renderPaste({ insertTypedText }) + await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) + expect(insertTypedText).toHaveBeenCalledWith('caption') + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) + expect(probe.chips).toMatchObject([{ path: storedPath, pending: false }]) + }) + + it('pastes from the button into the chat store', async () => { + mocks.readClipboardImageThumbnail.mockResolvedValue({ dataUrl: 'data:image/png;base64,AA' }) + const probe = await renderPaste({}) + await act(async () => probe.api().pasteFromClipboard()) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledExactlyOnceWith(storeArgs) + expect(probe.chips).toEqual([{ id: 'chip-1', path: storedPath, pending: false }]) + }) + + it('attaches the server path when no placeholder chip was shown', async () => { + const attachResolvedPaths = vi.fn() + const probe = await renderPaste({ attachResolvedPaths }) + await act(async () => probe.api().pasteFromClipboard()) + expect(attachResolvedPaths).toHaveBeenCalledExactlyOnceWith([storedPath], null) + }) + + it('refuses on a server without the attachment store and saves nothing', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: false, + notice: 'needs newer server' + }) + const setNotice = vi.fn() + const probe = await renderPaste({ setNotice }) + await act(async () => probe.api().handlePaste(imagePasteEvent())) + expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled() + expect(setNotice).toHaveBeenLastCalledWith('needs newer server') + expect(probe.chips).toHaveLength(0) + }) + + it('keeps the save error apart from the image-paste notice', async () => { + mocks.saveClipboardImageAsTempFile.mockRejectedValue( + new Error("Error invoking remote method 'ui:saveClipboardImageAsTempFile': Error: disk full") + ) + const setNotice = vi.fn() + const probe = await renderPaste({ setNotice }) + await act(async () => probe.api().handlePaste(imagePasteEvent())) + expect(setNotice).toHaveBeenLastCalledWith('Image paste failed.', 'disk full') + }) + + it('pastes rich text into a chat on an older server without a refusal beside the text', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: false, + notice: 'needs newer server' + }) + const insertTypedText = vi.fn(() => true) + const setNotice = vi.fn() + const probe = await renderPaste({ insertTypedText, setNotice }) + await act(async () => probe.api().handlePaste(imagePasteEvent('caption'))) + expect(insertTypedText).toHaveBeenCalledWith('caption') + expect(setNotice).not.toHaveBeenCalledWith('needs newer server') + // The image was owed to the message only out of sight: no chip showed, so none flashed. + expect(probe.begun()).toBe(1) + expect(probe.revealed()).toBe(0) + expect(probe.chips).toEqual([]) + }) + + it('pastes rich text from the button into a chat on an older server without a refusal', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValue({ + ok: false, + notice: 'needs newer server' + }) + mocks.readClipboardText.mockResolvedValue('caption') + const insertTypedText = vi.fn(() => true) + const setNotice = vi.fn() + const probe = await renderPaste({ insertTypedText, setNotice }) + await act(async () => probe.api().pasteFromClipboard()) + expect(insertTypedText).toHaveBeenCalledWith('caption') + expect(setNotice).not.toHaveBeenCalledWith('needs newer server') + }) + + it('files a paste whose upload lands after a prompt card unmounted the composer', async () => { + let finishUpload: (path: string) => void = () => {} + mocks.saveClipboardImageAsTempFile.mockReturnValue( + new Promise((resolve) => { + finishUpload = resolve + }) + ) + const resolved: string[] = [] + const dropped: string[] = [] + let api: HookApi | null = null + function Probe(): null { + api = useNativeChatComposerPaste({ + targetKey: 'session-1', + agent: 'claude', + disabled: false, + caret: 0, + setCaret: () => {}, + resolveAttachmentOwner: () => sessionOwner, + attachResolvedPaths: () => {}, + beginPendingImageAttachment: () => 'chip-1', + // The composer's chips file a result that lands after it unmounted into its scope cache. + resolvePendingImageAttachment: (id, path) => resolved.push(`${id} ${path}`), + dropPendingImageAttachment: (id) => { + dropped.push(id) + }, + insertTypedText: () => true, + setNotice: () => {} + }) + return null + } + const container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + await act(async () => root?.render(createElement(Probe))) + await act(async () => api?.handlePaste(imagePasteEvent())) + expect(mocks.saveClipboardImageAsTempFile).toHaveBeenCalledTimes(1) + + act(() => root?.unmount()) + root = null + await act(async () => finishUpload(storedPath)) + + expect(dropped).toEqual([]) + expect(resolved).toEqual([`chip-1 ${storedPath}`]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index 636ba4017f85..1701c069b342 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -1,7 +1,7 @@ -import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef } from 'react' +import { useNativeChatPasteLifetime } from './use-native-chat-paste-lifetime' import { nativeChatAttachmentOwnerUnchanged } from './native-chat-resolved-path-ownership' import { assertClipboardTextWithinLimit } from '../../../../shared/clipboard-text' -import { translate } from '@/i18n/i18n' import { setNativeChatPasteFailure } from './native-chat-composer-notice' import type { AgentType } from '../../../../shared/agent-status-types' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' @@ -17,6 +17,10 @@ import { nativeChatWorktreeNotReadyNotice, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' +import { + ownerAcceptsClipboardImage, + saveNativeChatClipboardImage +} from './native-chat-clipboard-image-save' export type UseNativeChatComposerPasteArgs = { targetKey?: string @@ -29,21 +33,20 @@ export type UseNativeChatComposerPasteArgs = { * remote host, or the attached path names a file the agent cannot read. */ resolveAttachmentOwner: () => NativeChatAttachmentOwner attachResolvedPaths: (paths: string[], connectionId?: string | null) => void - beginPendingImageAttachment: (previewUrl?: string) => string | null + beginPendingImageAttachment: ( + previewUrl?: string, + pendingName?: string, + options?: { hidden?: true } + ) => string | null resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + /** Shows a chip begun hidden, with its thumbnail. */ + revealPendingImageAttachment?: (id: string, previewUrl?: string) => void dropPendingImageAttachment: (id: string) => void insertTypedText: (text: string) => boolean setCaret: (caret: number) => void setNotice: (notice: string | null, errorText?: string) => void } -/** Owners whose attachment path is a file this client can write right now. */ -function ownerAcceptsClipboardImage( - owner: NativeChatAttachmentOwner -): owner is Extract { - return owner.kind === 'local' || owner.kind === 'ssh' -} - export function useNativeChatComposerPaste({ targetKey, disabled, @@ -52,6 +55,7 @@ export function useNativeChatComposerPaste({ attachResolvedPaths, beginPendingImageAttachment, resolvePendingImageAttachment, + revealPendingImageAttachment, dropPendingImageAttachment, insertTypedText, setCaret, @@ -61,28 +65,17 @@ export function useNativeChatComposerPaste({ pasteFromClipboard: () => void } { const disabledRef = useRef(disabled) - const dropPendingRef = useRef(dropPendingImageAttachment) useLayoutEffect(() => { disabledRef.current = disabled - dropPendingRef.current = dropPendingImageAttachment - }, [disabled, dropPendingImageAttachment]) - const lifetime = useMemo( - () => ({ targetKey, active: false, pending: new Map() }), - [targetKey] - ) - useLayoutEffect(() => { - lifetime.active = true - return () => { - lifetime.active = false - for (const [id, preview] of lifetime.pending) { - if (preview.startsWith('blob:')) { - URL.revokeObjectURL(preview) - } - dropPendingRef.current(id) - } - lifetime.pending.clear() - } - }, [lifetime]) + }, [disabled]) + const { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } = + useNativeChatPasteLifetime({ + targetKey, + beginPendingImageAttachment, + resolvePendingImageAttachment, + revealPendingImageAttachment, + dropPendingImageAttachment + }) const canPaste = useCallback(() => lifetime.active && !disabledRef.current, [lifetime]) // A disabled composer still answers a paste, so it never vanishes silently. const showPasteUnavailable = useCallback(() => { @@ -91,35 +84,31 @@ export function useNativeChatComposerPaste({ } }, [lifetime, setNotice]) - // Image failures do not decide whether text can be inserted. + // Image failures do not decide whether text can be inserted. Beside pasted text, a server too old + // to store the image drops only the image rendition, as an owner that takes no images does. const saveClipboardImageForOwner = useCallback( - async ( - owner: NativeChatAttachmentOwner - ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => { - if (owner.kind === 'runtime') { - setNotice(nativeChatLocalAttachmentUnsupportedNotice()) - return { status: 'failed' } - } - try { - // SSH panes save the image on the remote host (SFTP) so the attached - // path is readable by the remote agent, matching terminal image paste. - const tempPath = await window.api.ui.saveClipboardImageAsTempFile( - owner.kind === 'ssh' ? { connectionId: owner.connectionId } : { forNativeChatDraft: true } - ) - return tempPath ? { status: 'saved', tempPath } : { status: 'empty' } - } catch (error) { - // A failed save must be visible: over SSH it fails whenever the - // connection drops, and a silent no-op reads as a broken paste. - if (canPaste()) { - setNativeChatPasteFailure( - setNotice, - error, - translate('components.native-chat.composer.imagePasteFailed', 'Image paste failed.') + ( + owner: NativeChatAttachmentOwner, + besidePastedText: () => Promise, + ready?: () => void + ) => + saveNativeChatClipboardImage(owner, { + ready, + setNotice: (notice, cause, errorText) => { + void (cause === 'serverTooOld' ? besidePastedText() : Promise.resolve(false)).then( + (quiet) => { + if (quiet || !canPaste()) { + return + } + if (errorText) { + setNotice(notice, errorText) + } else { + setNotice(notice) + } + } ) } - return { status: 'failed' } - } - }, + }), [canPaste, setNotice] ) @@ -196,20 +185,23 @@ export function useNativeChatComposerPaste({ // Why: snapshot the caret before the async temp-file round-trip — `caret` // state can move (further typing/selection) while the await is in flight. const caretAtPaste = caret - // The clipboard blob is already in this process, so the chip can show the - // real image on the same tick the paste happens — no round-trip at all. - const previewUrl = ownerAcceptsClipboardImage(owner) - ? URL.createObjectURL(imageFile) - : undefined - const pendingId = previewUrl ? beginPendingImageAttachment(previewUrl) : null - if (previewUrl && !pendingId) { - URL.revokeObjectURL(previewUrl) - } - if (pendingId) { - lifetime.pending.set(pendingId, previewUrl ?? '') - } + // Beside pasted text, a server too old to store the image drops it quietly, so its chip stays + // out of sight until the server answers; Send waits for it from the start all the same. + const awaitServer = Boolean(text) && owner.kind === 'runtime-session' + const chip = startImageChip(owner, imageFile, { + hidden: awaitServer, + canShow: () => ownerAcceptsClipboardImage(owner) && canPaste() + }) + const pendingId = chip.id void (async () => { - const saved = await saveClipboardImageForOwner(owner) + const saved = await saveClipboardImageForOwner( + owner, + async () => Boolean(text), + awaitServer ? chip.reveal : undefined + ) + if (keepStoreUploadAfterUnmount(pendingId, saved)) { + return + } if (saved.status !== 'saved' || !canPaste()) { if (pendingId) { lifetime.pending.delete(pendingId) @@ -224,7 +216,6 @@ export function useNativeChatComposerPaste({ })() }, [ - beginPendingImageAttachment, canPaste, lifetime, caret, @@ -232,6 +223,8 @@ export function useNativeChatComposerPaste({ insertTypedText, showPasteUnavailable, resolveAttachmentOwner, + keepStoreUploadAfterUnmount, + startImageChip, saveClipboardImageForOwner, setCaret, setNotice, @@ -286,14 +279,20 @@ export function useNativeChatComposerPaste({ return } const thumbnailPromise = window.api.ui.readClipboardImageThumbnail().catch(() => null) - const savePromise = saveClipboardImageForOwner(owner) + const savePromise = saveClipboardImageForOwner(owner, async () => { + const read = await textRead + return Boolean(read?.text) && read?.labelsFiles !== true + }) const thumbnail = await thumbnailPromise const pendingId = thumbnail && canPaste() ? beginPendingImageAttachment(thumbnail.dataUrl) : null if (pendingId) { - lifetime.pending.set(pendingId, thumbnail?.dataUrl ?? '') + track(pendingId, thumbnail?.dataUrl ?? '', owner) } const saved = await savePromise + if (keepStoreUploadAfterUnmount(pendingId, saved)) { + return + } if (!canPaste() || saved.status !== 'saved') { if (pendingId) { lifetime.pending.delete(pendingId) @@ -314,6 +313,8 @@ export function useNativeChatComposerPaste({ lifetime, dropPendingImageAttachment, insertTypedText, + keepStoreUploadAfterUnmount, + track, showPasteUnavailable, resolveAttachmentOwner, saveClipboardImageForOwner, diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index a5d2abc0b31b..44e48edb2abf 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -9,7 +9,14 @@ const mocks = vi.hoisted(() => ({ stat: vi.fn(), resolveNativeChatAttachmentOwner: vi.fn(), resolveNativeChatAttachmentOwnerForWorktree: vi.fn(), - uploadNativeChatAttachmentPaths: vi.fn() + uploadNativeChatAttachmentPaths: vi.fn(), + prepareNativeChatSessionAttachmentUpload: vi.fn(), + uploadNativeChatSessionAttachmentPaths: vi.fn(), + toastLoading: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { loading: mocks.toastLoading, dismiss: vi.fn(), error: vi.fn(), message: vi.fn() } })) vi.mock('@/store', () => ({ @@ -22,10 +29,15 @@ vi.mock('./native-chat-attachment-upload', async (importOriginal) => ({ ...(await importOriginal()), resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree: mocks.resolveNativeChatAttachmentOwnerForWorktree, - uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths + uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths, + prepareNativeChatSessionAttachmentUpload: mocks.prepareNativeChatSessionAttachmentUpload, + uploadNativeChatSessionAttachmentPaths: mocks.uploadNativeChatSessionAttachmentPaths })) import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' +import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop' +import type { AgentSessionAttachmentPathUploadResult } from '../../../../shared/agent-session-attachments' +import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision' type HookApi = ReturnType @@ -37,16 +49,29 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } { return { promise, resolve } } +function noPendingChips(): NativeChatPendingAttachmentChips { + return { + begin: vi.fn(() => null), + resolve: vi.fn(), + drop: vi.fn(() => true), + attachReferences: vi.fn() + } +} + function Probe({ disabled, structuredWorktreeId, + structuredSession, attachResolvedPaths, + pendingChips, setNotice, onReady }: { disabled: boolean structuredWorktreeId?: string + structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } attachResolvedPaths: (paths: string[]) => void + pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void onReady: (api: HookApi) => void }): null { @@ -54,8 +79,10 @@ function Probe({ useNativeChatExternalAttachments({ terminalTabId: 'tab-1', structuredWorktreeId, + structuredSession, disabled, attachResolvedPaths, + pendingChips, setNotice }) ) @@ -70,7 +97,9 @@ let root: Root | null = null async function renderProbe(args: { disabled?: boolean structuredWorktreeId?: string + structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null } attachResolvedPaths: (paths: string[]) => void + pendingChips?: NativeChatPendingAttachmentChips setNotice?: (notice: string | null) => void }): Promise<{ latest: () => HookApi @@ -89,7 +118,9 @@ async function renderProbe(args: { createElement(Probe, { disabled, structuredWorktreeId, + structuredSession: args.structuredSession, attachResolvedPaths: args.attachResolvedPaths, + pendingChips: args.pendingChips ?? noPendingChips(), setNotice: args.setNotice ?? (() => {}), onReady: (next) => { api = next @@ -470,4 +501,222 @@ describe('useNativeChatExternalAttachments', () => { 'This workspace changed hosts while attaching — drop the files again.' ) }) + + describe('a structured chat on a paired server', () => { + const session = { sessionId: 'session-1', runtimeEnvironmentId: 'env-1' } + const target = { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' + } + + /** `removed`: chips the user clicked away, which the composer no longer holds. */ + function trackingChips(): NativeChatPendingAttachmentChips & { + begun: string[] + removed: Set + } { + const begun: string[] = [] + const removed = new Set() + return { + begun, + removed, + begin: vi.fn((_preview?: string, name?: string) => { + begun.push(name ?? '') + return `chip-${begun.length}` + }), + resolve: vi.fn(), + drop: vi.fn((id: string) => !removed.has(id)), + attachReferences: vi.fn() + } + } + + function uploaded( + pairs: [sourcePath: string, path: string][], + failed: string[] = [] + ): AgentSessionAttachmentPathUploadResult { + return { + uploaded: pairs.map(([sourcePath, path]) => ({ sourcePath, path })), + skipped: [], + failed: failed.map((sourcePath) => ({ + sourcePath, + reason: `'${sourcePath.split('/').at(-1)}' is 60 MB, over the 50 MB chat attachment limit` + })) + } + } + + beforeEach(() => { + replaceRuntimeEnvironmentRevisions([{ id: 'env-1', createdAt: 1, pairingRevision: 7 }]) + mocks.prepareNativeChatSessionAttachmentUpload.mockReset().mockResolvedValue({ + ok: true, + target + }) + mocks.uploadNativeChatSessionAttachmentPaths.mockReset() + }) + + it('uploads into the chat store and attaches only server paths', async () => { + const upload = deferred() + mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips + }) + + act(() => probe.latest().attachExternalPaths(['/Users/me/shot.png', '/Users/me/notes.md'])) + // Pending chips appear before the upload settles, so Send waits for both files. + expect(chips.begun).toEqual(['shot.png', 'notes.md']) + await act(async () => + upload.resolve( + uploaded([ + ['/Users/me/shot.png', '/srv/agent-session-attachments/u1/shot.png'], + ['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md'] + ]) + ) + ) + + expect(mocks.uploadNativeChatSessionAttachmentPaths).toHaveBeenCalledWith( + ['/Users/me/shot.png', '/Users/me/notes.md'], + target + ) + expect(chips.resolve).toHaveBeenCalledExactlyOnceWith( + 'chip-1', + '/srv/agent-session-attachments/u1/shot.png', + null + ) + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-2') + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) + // The client never reads its own disk for these paths: the server stores the bytes. + expect(mocks.stat).not.toHaveBeenCalled() + }) + + it('does not insert a file whose chip the user removed while it uploaded', async () => { + const upload = deferred() + mocks.uploadNativeChatSessionAttachmentPaths.mockReturnValueOnce(upload.promise) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips + }) + + act(() => probe.latest().attachExternalPaths(['/Users/me/report.pdf', '/Users/me/notes.md'])) + chips.removed.add('chip-1') + await act(async () => + upload.resolve( + uploaded([ + ['/Users/me/report.pdf', '/srv/agent-session-attachments/u1/report.pdf'], + ['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md'] + ]) + ) + ) + + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) + }) + + it('names, in one notice, each file that did not attach', async () => { + mocks.uploadNativeChatSessionAttachmentPaths.mockResolvedValueOnce( + uploaded( + [['/Users/me/notes.md', '/srv/agent-session-attachments/u2/notes.md']], + ['/Users/me/huge.mov'] + ) + ) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const notices: (string | null)[] = [] + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips, + setNotice: (notice) => notices.push(notice) + }) + + await act(async () => + probe.latest().attachExternalPaths(['/Users/me/huge.mov', '/Users/me/notes.md']) + ) + + expect(chips.drop).toHaveBeenCalledWith('chip-1') + expect(chips.attachReferences).toHaveBeenCalledExactlyOnceWith([ + '/srv/agent-session-attachments/u2/notes.md' + ]) + // The cause, the size limit here, rides in the same notice. + expect(notices).toEqual([ + "Couldn't attach huge.mov. 'huge.mov' is 60 MB, over the 50 MB chat attachment limit." + ]) + expect(mocks.toastLoading).not.toHaveBeenCalled() + }) + + it('refuses on a server without the attachment store, uploading nothing', async () => { + mocks.prepareNativeChatSessionAttachmentUpload.mockResolvedValueOnce({ + ok: false, + notice: 'needs newer server' + }) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const notices: (string | null)[] = [] + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips, + setNotice: (notice) => notices.push(notice) + }) + + await act(async () => probe.latest().attachExternalPaths(['/Users/me/shot.png'])) + + expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled() + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') + expect(chips.attachReferences).not.toHaveBeenCalled() + expect(notices.at(-1)).toBe('needs newer server') + }) + + it('says which file did not attach when the upload itself fails, such as on a re-pair', async () => { + // Main refuses every chunk once the pairing changes, so the whole upload call fails. + mocks.uploadNativeChatSessionAttachmentPaths.mockRejectedValueOnce( + new Error('Runtime environment changed') + ) + const chips = trackingChips() + const attachResolvedPaths = vi.fn() + const notices: (string | null)[] = [] + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: session, + attachResolvedPaths, + pendingChips: chips, + setNotice: (notice) => notices.push(notice) + }) + + await act(async () => probe.latest().attachExternalPaths(['/Users/me/shot.png'])) + + expect(chips.resolve).not.toHaveBeenCalled() + expect(chips.drop).toHaveBeenCalledExactlyOnceWith('chip-1') + expect(chips.attachReferences).not.toHaveBeenCalled() + expect(notices).toEqual(["Couldn't attach shot.png. Runtime environment changed."]) + }) + + it('keeps a local structured chat on the worktree owner', async () => { + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + structuredWorktreeId: 'worktree-1', + structuredSession: { sessionId: 'session-1', runtimeEnvironmentId: null }, + attachResolvedPaths + }) + await act(async () => probe.latest().attachExternalPaths(['/local/a.txt'])) + expect(mocks.resolveNativeChatAttachmentOwnerForWorktree).toHaveBeenCalled() + expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled() + expect(attachResolvedPaths).toHaveBeenCalledWith(['/local/a.txt'], undefined, { + destinationIsCurrent: expect.any(Function) + }) + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index b23318be3141..b3f92042869a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -13,15 +13,23 @@ import { nativeChatWorktreeNotReadyNotice, resolveNativeChatAttachmentOwner, resolveNativeChatAttachmentOwnerForWorktree, + resolveNativeChatRuntimeSessionAttachmentOwner, uploadNativeChatAttachmentPaths, - type NativeChatAttachmentOwner + type NativeChatAttachmentOwner, + type NativeChatStructuredAttachmentSession } from './native-chat-attachment-upload' +import { + attachNativeChatSessionAttachmentPaths, + type NativeChatPendingAttachmentChips +} from './native-chat-session-attachment-drop' import { userNamedFileAccess } from '@/lib/local-file-access' import { findTerminalTabWorktreeId } from './native-chat-file-link' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string structuredWorktreeId?: string + /** The structured chat behind this composer; decides where its uploads are stored. */ + structuredSession?: NativeChatStructuredAttachmentSession /** Live composer-disabled state; read at await-resume via a ref so a flip * mid-upload doesn't attach into a guarded composer. */ disabled: boolean @@ -30,15 +38,24 @@ export type UseNativeChatExternalAttachmentsArgs = { connectionId?: string | null, options?: NativeChatResolvedPathOptions ) => void + /** Chips shown while a file uploads, so Send waits for it. */ + pendingChips: NativeChatPendingAttachmentChips setNotice: (notice: string | null) => void } -type ComposerWorkspace = { structuredWorktreeId?: string; terminalTabId: string } +type ComposerWorkspace = { + structuredWorktreeId?: string + terminalTabId: string + structuredSession?: NativeChatStructuredAttachmentSession +} function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerWorkspace): boolean { return ( captured.structuredWorktreeId === current.structuredWorktreeId && - captured.terminalTabId === current.terminalTabId + captured.terminalTabId === current.terminalTabId && + captured.structuredSession?.sessionId === current.structuredSession?.sessionId && + captured.structuredSession?.runtimeEnvironmentId === + current.structuredSession?.runtimeEnvironmentId ) } @@ -50,8 +67,10 @@ function isSameComposerWorkspace(captured: ComposerWorkspace, current: ComposerW export function useNativeChatExternalAttachments({ terminalTabId, structuredWorktreeId, + structuredSession, disabled, attachResolvedPaths, + pendingChips, setNotice }: UseNativeChatExternalAttachmentsArgs): { attachExternalPaths: (paths: string[]) => void @@ -65,19 +84,36 @@ export function useNativeChatExternalAttachments({ // The post-await gate asks which workspace this composer serves now, so it // reads the pane through a ref. Resolving through the render closure would // re-ask the workspace the upload started in — a comparison with itself. - const workspaceRef = useRef({ structuredWorktreeId, terminalTabId }) + const sessionId = structuredSession?.sessionId + const runtimeEnvironmentId = structuredSession?.runtimeEnvironmentId ?? null + const workspaceRef = useRef({ + structuredWorktreeId, + terminalTabId, + structuredSession + }) + useLayoutEffect(() => { + workspaceRef.current = { + structuredWorktreeId, + terminalTabId, + structuredSession: sessionId ? { sessionId, runtimeEnvironmentId } : undefined + } + }, [runtimeEnvironmentId, sessionId, structuredWorktreeId, terminalTabId]) + const pendingChipsRef = useRef(pendingChips) useLayoutEffect(() => { - workspaceRef.current = { structuredWorktreeId, terminalTabId } - }, [structuredWorktreeId, terminalTabId]) + pendingChipsRef.current = pendingChips + }, [pendingChips]) const resolveAttachmentOwner = useCallback(() => { - const workspace = workspaceRef.current - return workspace.structuredWorktreeId - ? resolveNativeChatAttachmentOwnerForWorktree( - useAppStore.getState(), - workspace.structuredWorktreeId - ) - : resolveNativeChatAttachmentOwner(useAppStore.getState(), workspace.terminalTabId) + const { structuredWorktreeId, structuredSession, terminalTabId } = workspaceRef.current + if (structuredWorktreeId && structuredSession?.runtimeEnvironmentId) { + return resolveNativeChatRuntimeSessionAttachmentOwner({ + sessionId: structuredSession.sessionId, + runtimeEnvironmentId: structuredSession.runtimeEnvironmentId + }) + } + return structuredWorktreeId + ? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId) + : resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId) }, []) const attachExternalPaths = useCallback( @@ -116,6 +152,17 @@ export function useNativeChatExternalAttachments({ isSameComposerWorkspace(capturedWorkspace, workspaceRef.current) && capturedWorktreeId === currentWorktreeId() && nativeChatAttachmentOwnerUnchanged(owner, resolveAttachmentOwner()) + if (owner.kind === 'runtime-session') { + void attachNativeChatSessionAttachmentPaths({ + paths, + owner, + chips: pendingChipsRef.current, + isAbandoned: () => disabledRef.current, + ownerStillCurrent, + setNotice + }) + return + } if (owner.kind !== 'ssh') { void (async () => { const readablePaths: string[] = [] diff --git a/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts new file mode 100644 index 000000000000..f801303dab14 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-paste-lifetime.ts @@ -0,0 +1,128 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' +import type { NativeChatAttachmentOwner } from './native-chat-attachment-upload' + +/** + * The pending chips of pastes this composer started, released when it unmounts or changes target. + * A paste uploading into a paired server's store outlives the composer, which a prompt card + * unmounts: its chip stays pending in the scope's attachment cache, where its result settles for + * the composer's return (`native-chat-pending-attachment-cache.ts`). + */ +export function useNativeChatPasteLifetime(args: { + targetKey?: string + beginPendingImageAttachment: ( + previewUrl?: string, + pendingName?: string, + options?: { hidden?: true } + ) => string | null + /** Files the result into the composer's scope even once this instance is unmounted. */ + resolvePendingImageAttachment: (id: string, path: string, connectionId?: string | null) => void + revealPendingImageAttachment?: (id: string, previewUrl?: string) => void + dropPendingImageAttachment: (id: string) => void +}): { + lifetime: { active: boolean; pending: Map } + track: (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => void + /** A paste's chip, shown at once with the clipboard's own thumbnail, or held out of sight + * (`hidden`) until `reveal`; `id` is null when the composer refused it. */ + startImageChip: ( + owner: NativeChatAttachmentOwner, + imageFile: Blob, + options: { hidden: boolean; canShow: () => boolean } + ) => { id: string | null; reveal: () => void } + keepStoreUploadAfterUnmount: ( + pendingId: string | null, + saved: { status: string; tempPath?: string } + ) => boolean +} { + const { targetKey, beginPendingImageAttachment, revealPendingImageAttachment } = args + const { resolvePendingImageAttachment, dropPendingImageAttachment } = args + const dropPendingRef = useRef(dropPendingImageAttachment) + useLayoutEffect(() => { + dropPendingRef.current = dropPendingImageAttachment + }, [dropPendingImageAttachment]) + const lifetime = useMemo( + () => ({ + targetKey, + active: false, + pending: new Map(), + storeUploads: new Set() + }), + [targetKey] + ) + useLayoutEffect(() => { + lifetime.active = true + return () => { + lifetime.active = false + for (const [id, preview] of lifetime.pending) { + if (preview.startsWith('blob:')) { + URL.revokeObjectURL(preview) + } + if (!lifetime.storeUploads.has(id)) { + dropPendingRef.current(id) + } + } + lifetime.pending.clear() + } + }, [lifetime]) + const track = useCallback( + (pendingId: string, preview: string, owner: NativeChatAttachmentOwner) => { + lifetime.pending.set(pendingId, preview) + if (owner.kind === 'runtime-session') { + lifetime.storeUploads.add(pendingId) + } + }, + [lifetime] + ) + const startImageChip = useCallback( + ( + owner: NativeChatAttachmentOwner, + imageFile: Blob, + options: { hidden: boolean; canShow: () => boolean } + ): { id: string | null; reveal: () => void } => { + if (options.hidden) { + const id = beginPendingImageAttachment(undefined, undefined, { hidden: true }) + if (id) { + track(id, '', owner) + } + // Through the scope's cache, so it shows even in a composer that came back since. + const reveal = (): void => { + if (!id) { + return + } + const previewUrl = lifetime.active ? URL.createObjectURL(imageFile) : undefined + if (previewUrl) { + lifetime.pending.set(id, previewUrl) + } + revealPendingImageAttachment?.(id, previewUrl) + } + return { id, reveal } + } + if (!options.canShow()) { + return { id: null, reveal: () => {} } + } + const previewUrl = URL.createObjectURL(imageFile) + const id = beginPendingImageAttachment(previewUrl) + if (id) { + track(id, previewUrl, owner) + } else { + URL.revokeObjectURL(previewUrl) + } + return { id, reveal: () => {} } + }, + [beginPendingImageAttachment, lifetime, revealPendingImageAttachment, track] + ) + const keepStoreUploadAfterUnmount = useCallback( + (pendingId: string | null, saved: { status: string; tempPath?: string }): boolean => { + if (!pendingId || lifetime.active || !lifetime.storeUploads.has(pendingId)) { + return false + } + if (saved.status === 'saved' && saved.tempPath) { + resolvePendingImageAttachment(pendingId, saved.tempPath, null) + } else { + dropPendingImageAttachment(pendingId) + } + return true + }, + [dropPendingImageAttachment, lifetime, resolvePendingImageAttachment] + ) + return { lifetime, track, startImageChip, keepStoreUploadAfterUnmount } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx index 10e4c781c0fe..5002dd49121e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.test.tsx @@ -100,6 +100,22 @@ describe('attachment guard follows what the host claims', () => { }) }) +describe('an attachment still uploading', () => { + it('holds a picked command, as Send is held, rather than send the chip without its path', async () => { + const { send, structuredTransport } = harness('claude') + const pending: NativeChatComposerImageAttachment = { + id: 'p1', + path: '', + pending: true, + pendingName: 'notes.pdf' + } + send('/review', [pending]) + await Promise.resolve() + expect(structuredTransport.send).not.toHaveBeenCalled() + expect(structuredTransport.onError).not.toHaveBeenCalled() + }) +}) + // The pane brings the latest into view on this: a conversation command at the press, a message // once admitted, and neither for a refusal or a command the chat does not run. describe('reports the sends that bring the latest into view', () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts index 0f4a1298c15f..2bff57f735e0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-structured-composer-send.ts @@ -69,7 +69,8 @@ export function useNativeChatStructuredComposerSend({ }: UseNativeChatStructuredComposerSendArgs): NativeChatStructuredComposerSend { return useCallback( async (text, attachments = imageAttachments, sentFrom): Promise => { - if (!structuredTransport) { + // A picked command is held like Send: it would carry attachments still uploading, pathless. + if (!structuredTransport || attachments.some((attachment) => attachment.pending)) { return } const hostCommand = isNativeChatStructuredHostCommand(text, agent, structuredTransport) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts index 2f118fb534a9..a832df64930c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -188,7 +188,8 @@ export function useStructuredAgentSessionOutbox(args: { // The one place that owns the refs, the React state and the storage write. const applyDisposition = useCallback( - (disposition: StructuredAgentSessionSendDisposition): void => { + (outcome: StructuredAgentSessionSendDisposition): void => { + const disposition = restoreWithdrawn.byRefusal(outcome) // Released here rather than in a `.finally`: the state write below is what re-runs the // drain, so a later microtask would leave the queue with no trigger to move on. inFlightIdRef.current = null @@ -196,7 +197,7 @@ export function useStructuredAgentSessionOutbox(args: { recordFailures(getStructuredAgentSessionOutbox(sessionId), disposition.entries) commitStructuredAgentSessionOutbox(sessionId, disposition.entries) }, - [recordFailures, sessionId] + [recordFailures, restoreWithdrawn, sessionId] ) const [drains, setDrains] = useState(0) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a2f89fb1fce8..99c8479abe59 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2906,6 +2906,7 @@ "agentRefused": "The agent turned this down.", "agentStarting": "The agent is still starting.", "answerFirst": "Answer the question or approval first.", + "attachmentExpired": "This attachment expired.", "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "Orca can't find this chat.", @@ -2938,6 +2939,7 @@ "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", "quitTerminalAgent": "Quit that agent to continue the chat here.", + "reattachFile": "Remove it and attach it again.", "recordFailed": "Orca couldn't save this to the chat's history.", "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 031038f149b2..167d35400bfc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18058,6 +18058,8 @@ "cannotRunHere": "Orca can't run this agent in a chat here.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't save this to the chat's history.", + "attachmentExpired": "This attachment expired.", + "reattachFile": "Remove it and attach it again.", "conversationCleared": "This conversation has been cleared.", "openCurrentConversation": "Open the current conversation to continue.", "clearUnfinished": "The last /clear didn't finish.", @@ -18155,6 +18157,9 @@ "skillScopeBuiltIn": "Built-in", "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", + "attachFailed": "Couldn't attach {{files}}.", + "attachFailedBecause": "Couldn't attach {{files}}. {{reason}}", + "uploadingFile": "Uploading {{name}}…", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", "draftNotSaved": "This draft couldn't be saved yet. Orca keeps trying.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d9cafabd60dd..6c91fcda740e 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17655,6 +17655,8 @@ "cannotRunHere": "Orca no puede ejecutar este agente en un chat aquí.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo guardar esto en el historial del chat.", + "attachmentExpired": "Este archivo adjunto caducó.", + "reattachFile": "Quítelo y vuelva a adjuntarlo.", "conversationCleared": "Esta conversación se borró.", "openCurrentConversation": "Abra la conversación actual para continuar.", "clearUnfinished": "El último /clear no terminó.", @@ -17698,6 +17700,9 @@ "stopDictation": "Detener dictado", "noSkills": "Sin habilidades coincidentes", "localAttachmentUnsupported": "Los archivos adjuntos locales no están disponibles para sesiones remotas.", + "attachFailed": "No se pudo adjuntar {{files}}.", + "attachFailedBecause": "No se pudo adjuntar {{files}}. {{reason}}", + "uploadingFile": "Subiendo {{name}}…", "removeAttachment": "Quitar archivo adjunto", "pastedImageLabel": "Imagen pegada", "copyImage": "Copiar imagen", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9999b2f99758..e61768d24f4b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17757,6 +17757,8 @@ "cannotRunHere": "Orca ne peut pas exécuter cet agent dans un chat ici.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu enregistrer ceci dans l'historique du chat.", + "attachmentExpired": "Cette pièce jointe a expiré.", + "reattachFile": "Retirez-la et joignez-la à nouveau.", "conversationCleared": "Cette conversation a été effacée.", "openCurrentConversation": "Ouvrez la conversation actuelle pour continuer.", "clearUnfinished": "Le dernier /clear ne s'est pas terminé.", @@ -17815,6 +17817,9 @@ "skillScopeBuiltIn": "Intégrée", "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Les pièces jointes locales ne sont pas disponibles pour les sessions distantes.", + "attachFailed": "Impossible de joindre {{files}}.", + "attachFailedBecause": "Impossible de joindre {{files}}. {{reason}}", + "uploadingFile": "Envoi de {{name}}…", "removeAttachment": "Retirer la pièce jointe", "pastedImageLabel": "Image collée", "draftNotSaved": "Ce brouillon n'a pas encore pu être enregistré. Orca continue d'essayer.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index ca313e7942ba..8d7461e6bcbb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17691,6 +17691,8 @@ "cannotRunHere": "Orca はここでこの Agent をチャットで実行できません。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこれをチャットの履歴に保存できませんでした。", + "attachmentExpired": "この添付ファイルは期限切れです。", + "reattachFile": "削除してから、もう一度添付してください。", "conversationCleared": "この会話はクリアされました。", "openCurrentConversation": "続けるには、現在の会話を開いてください。", "clearUnfinished": "前回の /clear は完了しませんでした。", @@ -17734,6 +17736,9 @@ "stopDictation": "ディクテーションを停止する", "noSkills": "一致するスキルがありません", "localAttachmentUnsupported": "ローカル添付ファイルはリモートセッションでは使用できません。", + "attachFailed": "{{files}} を添付できませんでした。", + "attachFailedBecause": "{{files}} を添付できませんでした。{{reason}}", + "uploadingFile": "{{name}} をアップロードしています…", "removeAttachment": "添付ファイルを削除する", "pastedImageLabel": "貼り付けた画像", "draftNotSaved": "この下書きはまだ保存できていません。Orca は保存を再試行し続けます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 49daef9ca7c8..dd5bf6513e9c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17691,6 +17691,8 @@ "cannotRunHere": "Orca는 여기에서 이 에이전트를 채팅으로 실행할 수 없습니다.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 내용을 채팅 기록에 저장하지 못했습니다.", + "attachmentExpired": "이 첨부 파일이 만료되었습니다.", + "reattachFile": "제거한 후 다시 첨부하세요.", "conversationCleared": "이 대화는 지워졌습니다.", "openCurrentConversation": "계속하려면 현재 대화를 여세요.", "clearUnfinished": "마지막 /clear가 완료되지 않았습니다.", @@ -17734,6 +17736,9 @@ "stopDictation": "받아쓰기 중지", "noSkills": "어울리는 스킬 없음", "localAttachmentUnsupported": "원격 세션에는 로컬 첨부 파일을 사용할 수 없습니다.", + "attachFailed": "{{files}}을(를) 첨부할 수 없습니다.", + "attachFailedBecause": "{{files}}을(를) 첨부할 수 없습니다. {{reason}}", + "uploadingFile": "{{name}} 업로드 중…", "removeAttachment": "첨부파일 삭제", "pastedImageLabel": "붙여넣은 이미지", "draftNotSaved": "이 초안을 아직 저장하지 못했습니다. Orca가 계속 다시 시도합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b19f3a7a1013..89a8f27d8328 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17656,6 +17656,8 @@ "cannotRunHere": "Orca 无法在此处以聊天方式运行此智能体。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将此内容保存到聊天历史记录中。", + "attachmentExpired": "此附件已过期。", + "reattachFile": "请将其移除后重新附加。", "conversationCleared": "此对话已被清除。", "openCurrentConversation": "请打开当前对话以继续。", "clearUnfinished": "上一次 /clear 未完成。", @@ -17699,6 +17701,9 @@ "stopDictation": "停止听写", "noSkills": "没有匹配的技能", "localAttachmentUnsupported": "远程会话不支持本地附件。", + "attachFailed": "无法附加 {{files}}。", + "attachFailedBecause": "无法附加 {{files}}。{{reason}}", + "uploadingFile": "正在上传 {{name}}…", "removeAttachment": "移除附件", "pastedImageLabel": "粘贴的图片", "draftNotSaved": "此草稿尚未保存成功。Orca 会继续尝试。", diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts index 8c7e336fc2de..7f2cd70ca5cd 100644 --- a/src/renderer/src/runtime/runtime-file-client.test.ts +++ b/src/renderer/src/runtime/runtime-file-client.test.ts @@ -513,6 +513,35 @@ describe('runtime file client', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) + // A chat attachment lives in the paired server's store, outside every worktree. + it.each([ + { worktreeId: 'wt-1', worktreePath: '/remote/repo' }, + { worktreeId: null, worktreePath: null } + ])('reads a stored chat attachment back through its server ($worktreeId)', async (owner) => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-1', + ok: true, + result: { content: 'base64', isBinary: true, isImage: true, mimeType: 'image/png' }, + _meta: { runtimeId: 'remote-runtime' } + }) + const storedPath = '/srv/orca/agent-session-attachments/abc/u1/shot.png' + + await expect( + readRuntimeFilePreview( + { settings: { activeRuntimeEnvironmentId: 'env-1' }, ...owner }, + storedPath + ) + ).resolves.toMatchObject({ content: 'base64', mimeType: 'image/png' }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'agentSessionAttachment.read', + params: { path: storedPath }, + timeoutMs: 15_000 + }) + expect(fsReadFile).not.toHaveBeenCalled() + }) + it('routes root directory reads with an empty relative path', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts index f05599752912..3cd9943fa40e 100644 --- a/src/renderer/src/runtime/runtime-file-read-client.ts +++ b/src/renderer/src/runtime/runtime-file-read-client.ts @@ -17,6 +17,7 @@ import { hasRemoteRuntimeOwner } from './runtime-file-routing' import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' +import { isAgentSessionAttachmentStorePath } from '../../../shared/agent-session-attachments' import type { LocalFileAccess } from '../../../shared/local-file-access' const REMOTE_DOWNLOAD_CHUNK_BYTES = 384 * 1024 @@ -99,6 +100,21 @@ export async function readRuntimeFilePreview( context.expectedExternalSshTargetId ) const remoteArgs = getRemoteFileArgs(context, filePath) + const runtimeTarget = getActiveRuntimeTarget(context.settings) + // A chat attachment lives in the paired server's store, outside every worktree; that server + // reads it back itself (an older one answers method_not_found, and the preview falls back). + if ( + !remoteArgs && + runtimeTarget.kind === 'environment' && + isAgentSessionAttachmentStorePath(filePath) + ) { + return callRuntimeRpc( + runtimeTarget, + 'agentSessionAttachment.read', + { path: filePath }, + { timeoutMs: 15_000 } + ) + } if (!remoteArgs) { if (hasRemoteRuntimeOwner(context)) { throw new Error('Remote file is outside the owning runtime worktree') diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index 915ef64300e6..ca3c982eeace 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -51,6 +51,10 @@ import { structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { + addNativeChatPendingAttachment, + settleNativeChatPendingAttachment +} from '@/components/native-chat/native-chat-pending-attachment-cache' const WT1 = 'repo1::/path/wt1' const WT2 = 'repo1::/path/wt2' @@ -190,6 +194,23 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { expect(readNativeChatComposerDraft(`${TAB2}:leaf-a`).text).toBe('kept') }) + it('a user tab close drops the uploads still on their way in that tab’s panes only', () => { + const store = createTestStore() + seedDrafts(store) + for (const scopeKey of [`${TAB1}:leaf-b`, `${TAB2}:leaf-b`]) { + addNativeChatPendingAttachment(scopeKey, { id: scopeKey, path: '', pending: true }) + } + + store.getState().closeTab(TAB2, { reason: 'pty-exit' }) + store.getState().closeTab(TAB1) + + const settled = [`${TAB1}:leaf-b`, `${TAB2}:leaf-b`].map((scopeKey) => + settleNativeChatPendingAttachment(scopeKey, scopeKey, '/store/a.png') + ) + expect(settled).toEqual([false, true]) + expect(readNativeChatComposerDraft(`${TAB1}:leaf-b`).images).toEqual([]) + }) + function seedStructuredChat(store: ReturnType) { const chat = makeUnifiedTab({ id: 'chat-tab', diff --git a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts index 0f81e7f6c573..bac3dc738cd6 100644 --- a/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts +++ b/src/renderer/src/store/slices/workspace-chat-draft-removal.test.ts @@ -43,11 +43,17 @@ import { } from './store-test-helpers' import { clearNativeChatComposerDraftsForTests, + hydrateNativeChatComposerDrafts, readNativeChatComposerDraft, setNativeChatComposerDraftOwnerResolver, structuredAgentSessionDraftScopeKey, updateNativeChatComposerDraft } from '@/components/native-chat/native-chat-composer-draft-store' +import { + addNativeChatPendingAttachment, + clearNativeChatPendingAttachmentsForTests, + settleNativeChatPendingAttachment +} from '@/components/native-chat/native-chat-pending-attachment-cache' import { resolveNativeChatDraftOwner } from '@/lib/native-chat-draft-owner' const WT1 = 'repo1::/path/wt1' @@ -102,12 +108,75 @@ function draftTexts(): string[] { return [CHAT, PANE, OTHER].map((key) => readNativeChatComposerDraft(key).text) } +/** A pasted image still uploading in each scope; its chip's id is the scope's. */ +function pasteStillUploading(...scopeKeys: string[]): void { + for (const scopeKey of scopeKeys) { + addNativeChatPendingAttachment(scopeKey, { id: scopeKey, path: '', pending: true }) + } +} + +/** Each upload completes, as one does after its composer is gone; answers which scopes it reached. */ +function uploadsComplete(...scopeKeys: string[]): string[] { + return scopeKeys.filter((scopeKey) => + settleNativeChatPendingAttachment(scopeKey, scopeKey, `/store/${scopeKey}.png`) + ) +} + +function draftImageCount(scopeKey: string): number { + return readNativeChatComposerDraft(scopeKey).images.length +} + +function trackDraftOwners(store: ReturnType): void { + setNativeChatComposerDraftOwnerResolver((scopeKey) => + resolveNativeChatDraftOwner(store.getState(), scopeKey) + ) +} + +const FOLDER: FolderWorkspace = { + id: 'folder-1', + projectGroupId: 'group-a', + name: 'Folder', + folderPath: '/workspace/folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 1, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 +} + +function seedFolderWorkspace(store: ReturnType): void { + store.setState({ + projectGroups: [ + { + id: 'group-a', + name: 'A', + parentPath: '/workspace', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + executionHostId: 'local' + } + ], + folderWorkspaces: [FOLDER] + }) + seedWorkspace(store, folderWorkspaceKey(FOLDER.id)) +} + describe('workspace chat drafts on removal', () => { beforeEach(() => { vi.clearAllMocks() mockApi.worktrees.remove.mockResolvedValue(undefined) mockApi.repos.remove.mockResolvedValue(undefined) clearNativeChatComposerDraftsForTests() + clearNativeChatPendingAttachmentsForTests() }) it('removing a worktree deletes its drafts even when a listing purge lands mid-removal', async () => { @@ -223,45 +292,94 @@ describe('workspace chat drafts on removal', () => { }) it('deleting a folder workspace deletes its drafts', async () => { - const folder: FolderWorkspace = { - id: 'folder-1', - projectGroupId: 'group-a', - name: 'Folder', - folderPath: '/workspace/folder', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 1, - lastActivityAt: 0, - createdAt: 1, - updatedAt: 1 - } mockApi.folderWorkspaces.delete.mockResolvedValue(true) const store = createTestStore() - store.setState({ - projectGroups: [ - { - id: 'group-a', - name: 'A', - parentPath: '/workspace', - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 1, - updatedAt: 1, - executionHostId: 'local' - } - ], - folderWorkspaces: [folder] - }) - seedWorkspace(store, folderWorkspaceKey(folder.id)) + seedFolderWorkspace(store) + + await expect(store.getState().deleteFolderWorkspace(FOLDER.id)).resolves.toBe(true) + + expect(draftTexts()).toEqual(['', '', 'other']) + }) + + it('a paste still uploading when its folder workspace is deleted never writes a draft back', async () => { + mockApi.folderWorkspaces.delete.mockResolvedValue(true) + const store = createTestStore() + seedFolderWorkspace(store) + trackDraftOwners(store) + pasteStillUploading(CHAT, PANE, OTHER) - await expect(store.getState().deleteFolderWorkspace(folder.id)).resolves.toBe(true) + await expect(store.getState().deleteFolderWorkspace(FOLDER.id)).resolves.toBe(true) + expect(uploadsComplete(CHAT, PANE, OTHER)).toEqual([OTHER]) + expect([CHAT, PANE, OTHER].map(draftImageCount)).toEqual([0, 0, 1]) expect(draftTexts()).toEqual(['', '', 'other']) }) + + it('drops the uploads of its open chats and panes even when no owner was named', async () => { + const store = createTestStore() + seedWorktree(store) + pasteStillUploading(CHAT, PANE, OTHER) + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(uploadsComplete(CHAT, PANE, OTHER)).toEqual([OTHER]) + expect([CHAT, PANE, OTHER].map(draftImageCount)).toEqual([0, 0, 1]) + }) + + it('drops the upload of a chat whose tab closed while it was still on its way', async () => { + const store = createTestStore() + seedWorktree(store) + trackDraftOwners(store) + pasteStillUploading(CHAT) + store.getState().closeUnifiedTab('chat-tab') + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(uploadsComplete(CHAT)).toEqual([]) + expect(draftImageCount(CHAT)).toBe(0) + }) + + it('deletes a draft an upload wrote after its chat’s tab closed, by the owner it began in', async () => { + const store = createTestStore() + seedWorktree(store) + trackDraftOwners(store) + const later = structuredAgentSessionDraftScopeKey('session-later') + seedStore(store, { + unifiedTabsByWorktree: { + [WT1]: [ + ...(store.getState().unifiedTabsByWorktree[WT1] ?? []), + makeUnifiedTab({ + id: 'later-tab', + entityId: 'session-later', + contentType: 'agent-session', + worktreeId: WT1, + groupId: 'group-1' + }) + ] + } + }) + // As at startup: the saved drafts have loaded before any upload settles. + await hydrateNativeChatComposerDrafts() + pasteStillUploading(later) + store.getState().closeUnifiedTab('later-tab') + expect(uploadsComplete(later)).toEqual([later]) + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(draftImageCount(later)).toBe(0) + }) + + it('a removal the host refuses keeps the uploads, which still reach their drafts', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = createTestStore() + seedWorktree(store) + trackDraftOwners(store) + pasteStillUploading(CHAT, PANE) + mockApi.worktrees.remove.mockRejectedValue(new Error('Permission denied')) + + await store.getState().removeWorktree({ id: WT1, executionHostId: null }, true) + + expect(uploadsComplete(CHAT, PANE)).toEqual([CHAT, PANE]) + expect([CHAT, PANE].map(draftImageCount)).toEqual([1, 1]) + }) }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts index 14ae90b6e746..2579a1322f29 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-chat-drafts.ts @@ -7,6 +7,11 @@ import { deleteNativeChatComposerDraftsOwnedBy, structuredAgentSessionDraftScopeKey } from '@/components/native-chat/native-chat-composer-draft-store' +import { + clearNativeChatPendingAttachments, + dropNativeChatPendingAttachmentsForTab, + dropNativeChatPendingAttachmentsOwnedBy +} from '@/components/native-chat/native-chat-pending-attachment-cache' /** Which unsent chat drafts a workspace owns: every draft that recorded it as its owner, open and * closed chats alike, plus its open tabs' conversation keys and terminal tab ids (each tab's pane @@ -47,15 +52,19 @@ export function captureWorkspaceChatDraftKeys( return { owners, conversations, terminalTabIds } } -/** Only after the delete succeeded: a refused or failed one keeps the drafts. */ +/** Only after the delete succeeded: a refused or failed one keeps the drafts. Chips still on their + * way go too, or one settling later would write a draft back that nothing could delete. */ export function deleteWorkspaceChatDrafts(keys: WorkspaceChatDraftKeys): void { for (const owner of keys.owners) { deleteNativeChatComposerDraftsOwnedBy(owner) + dropNativeChatPendingAttachmentsOwnedBy(owner) } for (const key of keys.conversations) { deleteNativeChatComposerDraft(key) + clearNativeChatPendingAttachments(key) } for (const tabId of keys.terminalTabIds) { deleteNativeChatComposerDraftsForTab(tabId) + dropNativeChatPendingAttachmentsForTab(tabId) } } diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 198a2e114730..9361391a9e67 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -18,6 +18,7 @@ import { omitUnverifiedPtyLossTabIds } from './terminal-unverified-pty-loss' import { removePaneKeysByTabPrefix } from '../slices/agent-status-pane-keyed-records' import { omitRecordKeys } from '../slices/worktrees/teardown/record-key-omission' import { deleteNativeChatComposerDraftsForTab } from '@/components/native-chat/native-chat-composer-draft-store' +import { dropNativeChatPendingAttachmentsForTab } from '@/components/native-chat/native-chat-pending-attachment-cache' import { noteAgentLaunchPaneClosedByUser } from '@/lib/agent-launch-pane-closes' export function createTerminalTabCloseActions( @@ -264,6 +265,7 @@ export function createTerminalTabCloseActions( // workspace is removed in Orca; drafts have no budget that retires them. if (closeReason === 'user') { deleteNativeChatComposerDraftsForTab(tabId) + dropNativeChatPendingAttachmentsForTab(tabId) } // Why shared with the paired snapshot apply: every path that removes a tab owes it the same sweep, and a second copy of the list is how one path silently misses a new entry. sweepRetiredTerminalTabState(get(), tabId, closingWorktreeId) diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts new file mode 100644 index 000000000000..4b1e6b56af93 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type TestEnvironment = { id: string; createdAt: number; pairingRevision?: number } + +const mocks = vi.hoisted(() => { + const activeEnvironment: TestEnvironment = { id: 'env-1', createdAt: 1, pairingRevision: 7 } + return { callEnvironmentEnvelope: vi.fn(), activeEnvironment } +}) + +vi.mock('./web-runtime-calls', () => ({ callEnvironmentEnvelope: mocks.callEnvironmentEnvelope })) +vi.mock('./web-runtime-session', () => ({ + resolveEnvironment: (selector: string) => { + if (selector !== mocks.activeEnvironment.id) { + throw new Error(`Unknown Orca runtime environment: ${selector}`) + } + return mocks.activeEnvironment + } +})) +vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'uuid-1' })) + +import { saveClipboardImageAsWebAgentSessionAttachment } from './web-agent-session-attachment-upload' + +const target = { + environmentId: 'env-1', + sessionId: 'session-1', + expectedEnvironmentPairingRevision: 7, + expectedEnvironmentRuntimeId: 'runtime-a' +} + +function reply(result: unknown, runtimeId = 'runtime-a') { + return { id: 'r', ok: true, result, _meta: { runtimeId } } +} + +beforeEach(() => { + mocks.callEnvironmentEnvelope.mockReset() + mocks.activeEnvironment = { id: 'env-1', createdAt: 1, pairingRevision: 7 } +}) + +describe('saveClipboardImageAsWebAgentSessionAttachment', () => { + it('uploads to the server the paste was meant for and returns its stored path', async () => { + mocks.callEnvironmentEnvelope.mockImplementation(async (_env: string, method: string) => + method === 'agentSessionAttachment.uploadStart' + ? reply({ uploadId: 'u1' }) + : method === 'agentSessionAttachment.uploadCommit' + ? reply({ path: '/srv/agent-session-attachments/u1/p.png', name: 'p.png', byteLength: 3 }) + : reply({ receivedBytes: 3 }) + ) + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), target) + ).resolves.toBe('/srv/agent-session-attachments/u1/p.png') + for (const call of mocks.callEnvironmentEnvelope.mock.calls) { + expect(call[0]).toBe('env-1') + } + }) + + it('stops when a different server process answers, storing nothing more', async () => { + mocks.callEnvironmentEnvelope.mockResolvedValue(reply({ uploadId: 'u1' }, 'runtime-b')) + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), target) + ).rejects.toThrow('paired Orca server changed') + expect(mocks.callEnvironmentEnvelope).toHaveBeenCalledTimes(1) + }) + + it('refuses before uploading once the server was re-paired', async () => { + mocks.activeEnvironment = { id: 'env-1', createdAt: 1, pairingRevision: 8 } + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), target) + ).rejects.toThrow('paired Orca server changed') + expect(mocks.callEnvironmentEnvelope).not.toHaveBeenCalled() + }) + + it('refuses when the paste was meant for a server this client no longer pairs with', async () => { + await expect( + saveClipboardImageAsWebAgentSessionAttachment(Buffer.from('png').toString('base64'), { + ...target, + environmentId: 'env-other' + }) + ).rejects.toThrow('Unknown Orca runtime environment') + expect(mocks.callEnvironmentEnvelope).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts new file mode 100644 index 000000000000..b32c0bc63405 --- /dev/null +++ b/src/renderer/src/web/preload-api/web-agent-session-attachment-upload.ts @@ -0,0 +1,89 @@ +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + agentSessionPastedImageName, + type AgentSessionAttachmentUploadCommitResult, + type AgentSessionAttachmentUploadTarget +} from '../../../../shared/agent-session-attachments' +import { callEnvironmentEnvelope } from './web-runtime-calls' +import { resolveEnvironment } from './web-runtime-session' +import { createBrowserUuid } from '@/lib/browser-uuid' + +const ATTACHMENT_CALL_TIMEOUT_MS = 30_000 +const SERVER_CHANGED = 'The paired Orca server changed while the request was in progress.' + +/** Every call goes to the server the paste was meant for, as its desktop counterpart does: a + * re-pair or a replaced server process ends the upload instead of storing it elsewhere. */ +async function callPinned( + target: AgentSessionAttachmentUploadTarget, + method: string, + params: unknown +): Promise { + const environment = resolveEnvironment(target.environmentId) + if ( + target.expectedEnvironmentPairingRevision !== undefined && + (environment.pairingRevision ?? environment.createdAt) !== + target.expectedEnvironmentPairingRevision + ) { + throw new Error(SERVER_CHANGED) + } + const response = await callEnvironmentEnvelope( + environment.id, + method, + params, + ATTACHMENT_CALL_TIMEOUT_MS + ) + // A failure may come from this side and name no server process; a success always names one. + const runtimeId = response._meta?.runtimeId ?? null + if ((response.ok || runtimeId !== null) && runtimeId !== target.expectedEnvironmentRuntimeId) { + throw new Error(SERVER_CHANGED) + } + if (!response.ok) { + throw new Error(response.error.message || response.error.code) + } + return response.result +} + +/** The browser client's paste into a structured chat: the same store the desktop client uses. */ +export async function saveClipboardImageAsWebAgentSessionAttachment( + contentBase64: string, + target: AgentSessionAttachmentUploadTarget +): Promise { + const padding = contentBase64.endsWith('==') ? 2 : contentBase64.endsWith('=') ? 1 : 0 + const byteLength = (contentBase64.length / 4) * 3 - padding + const { uploadId } = await callPinned<{ uploadId: string }>( + target, + 'agentSessionAttachment.uploadStart', + { + sessionId: target.sessionId, + name: agentSessionPastedImageName(Date.now(), createBrowserUuid()), + byteLength + } + ) + try { + // Chunks are a multiple of 4 base64 characters, so each starts on a byte boundary. + for ( + let charOffset = 0; + charOffset < Math.max(contentBase64.length, 1); + charOffset += AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) { + await callPinned(target, 'agentSessionAttachment.uploadAppend', { + uploadId, + offset: (charOffset / 4) * 3, + contentBase64: contentBase64.slice( + charOffset, + charOffset + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS + ) + }) + } + const stored = await callPinned( + target, + 'agentSessionAttachment.uploadCommit', + { uploadId } + ) + return stored.path + } catch (error) { + // Only to the same server: an abort a re-pair refuses is left to that server's idle expiry. + await callPinned(target, 'agentSessionAttachment.uploadAbort', { uploadId }).catch(() => {}) + throw error + } +} diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts index ec7c0a7e1798..e11ac26b0eb1 100644 --- a/src/renderer/src/web/preload-api/web-filesystem-api.ts +++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts @@ -168,6 +168,7 @@ export function createFileApi(): NonNullable['fs']> { throw new Error('Uploading local files is not supported in the web client') }, resolveDroppedPathsForAgent: async () => ({ resolvedPaths: [], skipped: [], failed: [] }), + uploadPathsToAgentSessionAttachments: async () => ({ uploaded: [], skipped: [], failed: [] }), watchWorktree: () => Promise.resolve(), unwatchWorktree: () => Promise.resolve(), onFsChanged: () => noopUnsubscribe diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index b015e1647b4e..b70be2880e4b 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -1,3 +1,5 @@ +import type { AgentSessionAttachmentClipboardTarget } from '../../../../shared/agent-session-attachments' +import { saveClipboardImageAsWebAgentSessionAttachment } from './web-agent-session-attachment-upload' import { createWebExplorerRootSync } from './web-explorer-root-sync' import type { PreloadApi } from '../../../../preload/api-types' import { assertClipboardTextWithinLimitWithYield } from '../../../../shared/clipboard-text' @@ -149,6 +151,7 @@ export function createWebUiApi(): NonNullable['ui']> { saveClipboardImageAsTempFile: async (args?: { connectionId?: string | null runtimeEnvironmentId?: string | null + agentSessionAttachment?: AgentSessionAttachmentClipboardTarget }) => { if (!requireActiveEnvironmentOrNull()) { return null @@ -157,6 +160,15 @@ export function createWebUiApi(): NonNullable['ui']> { if (!contentBase64) { return null } + if (args?.agentSessionAttachment) { + if (!args.runtimeEnvironmentId) { + throw new Error('A chat attachment needs the server the chat runs on.') + } + return saveClipboardImageAsWebAgentSessionAttachment(contentBase64, { + ...args.agentSessionAttachment, + environmentId: args.runtimeEnvironmentId + }) + } return saveClipboardImageAsTempFileInRuntime(contentBase64, args) }, clipboardHasImage, diff --git a/src/shared/agent-session-attachments.test.ts b/src/shared/agent-session-attachments.test.ts new file mode 100644 index 000000000000..c48b8964c18d --- /dev/null +++ b/src/shared/agent-session-attachments.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES, + sanitizeAgentSessionAttachmentName +} from './agent-session-attachments' + +const bytes = (text: string): number => new TextEncoder().encode(text).byteLength + +describe('sanitizeAgentSessionAttachmentName', () => { + it('keeps only the last path segment', () => { + expect(sanitizeAgentSessionAttachmentName('../../etc/passwd')).toBe('passwd') + expect(sanitizeAgentSessionAttachmentName('C:\\Users\\me\\shot.png')).toBe('shot.png') + }) + + it('caps a long name in UTF-8 bytes on whole characters, keeping its extension', () => { + const name = sanitizeAgentSessionAttachmentName(`${'图'.repeat(120)}.pdf`) + expect(bytes(name)).toBeLessThanOrEqual(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES) + expect(name.endsWith('.pdf')).toBe(true) + expect(name).not.toContain('\uFFFD') + }) + + it('never ends in a dot or space, even where the cut lands on one', () => { + // No short extension to keep, so the cut falls inside the name, right after a dot. + const long = `${'a'.repeat(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - 1)}.${'b'.repeat(30)}` + const name = sanitizeAgentSessionAttachmentName(long) + expect(name).toBe('a'.repeat(AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - 1)) + expect(sanitizeAgentSessionAttachmentName('notes. .')).toBe('notes') + }) + + it('renames what Windows reserves for devices', () => { + expect(sanitizeAgentSessionAttachmentName('CON')).toBe('_CON') + expect(sanitizeAgentSessionAttachmentName('nul.tar.gz')).toBe('_nul.tar.gz') + expect(sanitizeAgentSessionAttachmentName('console.log')).toBe('console.log') + }) +}) diff --git a/src/shared/agent-session-attachments.ts b/src/shared/agent-session-attachments.ts new file mode 100644 index 000000000000..d2fabcabd0e0 --- /dev/null +++ b/src/shared/agent-session-attachments.ts @@ -0,0 +1,114 @@ +// Files a client attaches to a structured chat on a paired Orca server: the client uploads the +// bytes, the server keeps them in a store it owns, and the agent only ever sees the server path. + +import type { ImportSkipReason } from './filesystem-import-result-types' + +/** Directory name of the server's store, under its userData. Also how a client recognizes a + * stored attachment's path without a round trip. */ +export const AGENT_SESSION_ATTACHMENTS_DIR_NAME = 'agent-session-attachments' + +export const AGENT_SESSION_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024 + +/** 384 KiB of bytes is 512 KiB of base64 on the wire, the same frame size file imports use. */ +export const AGENT_SESSION_ATTACHMENT_CHUNK_BYTES = 384 * 1024 + +export const AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS = + (AGENT_SESSION_ATTACHMENT_CHUNK_BYTES / 3) * 4 + +export const AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES = 200 + +export type AgentSessionAttachmentUploadStartResult = { uploadId: string } +export type AgentSessionAttachmentUploadCommitResult = { + path: string + name: string + byteLength: number +} + +/** Whether `filePath` names a file in some server's attachment store. Only routes a preview read; + * the server re-checks the real path before reading anything. */ +export function isAgentSessionAttachmentStorePath(filePath: string): boolean { + return filePath.split(/[\\/]/).includes(AGENT_SESSION_ATTACHMENTS_DIR_NAME) +} + +// Windows refuses these as a file's name whatever its extension, and drops trailing dots and spaces. +const WINDOWS_DEVICE_NAME = + /^(con|prn|aux|nul|com[0-9\u00b9\u00b2\u00b3]|lpt[0-9\u00b9\u00b2\u00b3])$/i + +function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).byteLength +} + +/** Whole characters from the start of `text` that fit in `maxBytes` of UTF-8. */ +function truncateUtf8(text: string, maxBytes: number): string { + let kept = '' + let bytes = 0 + for (const character of text) { + bytes += utf8ByteLength(character) + if (bytes > maxBytes) { + break + } + kept += character + } + return kept +} + +/** + * A stored file keeps the user's file name, since agents read meaning into names and extensions. + * Only the last path segment survives, without control characters or separators, short enough in + * bytes for every server's file system and never a name Windows reserves. + */ +export function sanitizeAgentSessionAttachmentName(name: string): string { + const leaf = name.split(/[\\/]/).findLast((part) => part.length > 0) ?? '' + // eslint-disable-next-line no-control-regex -- stripping control characters is the point + const cleaned = leaf.replace(/[\u0000-\u001f\u007f<>:"|?*]/g, '_').trim() + let visible = cleaned.replace(/^\.+/, '').replace(/[. ]+$/, '') + if (visible.length === 0) { + return 'attachment' + } + const firstDot = visible.indexOf('.') + if (WINDOWS_DEVICE_NAME.test(firstDot > 0 ? visible.slice(0, firstDot) : visible)) { + visible = `_${visible}` + } + if (utf8ByteLength(visible) <= AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES) { + return visible + } + const extensionAt = visible.lastIndexOf('.') + const extension = + extensionAt > 0 && utf8ByteLength(visible.slice(extensionAt)) <= 16 + ? visible.slice(extensionAt) + : '' + const stem = visible.slice(0, visible.length - extension.length) + const truncated = + truncateUtf8(stem, AGENT_SESSION_ATTACHMENT_NAME_MAX_BYTES - utf8ByteLength(extension)) + + extension + // Cutting can expose a trailing dot or space, which Windows drops from a name. + return truncated.replace(/[. ]+$/, '') || 'attachment' +} + +/** Pins an upload to the server the attachment was meant for: every call re-checks the pairing, + * and every chunk the server process, so a re-pair or a replaced server aborts the upload. */ +export type AgentSessionAttachmentUploadTarget = { + environmentId: string + sessionId: string + expectedEnvironmentPairingRevision?: number + expectedEnvironmentRuntimeId: string +} + +export type AgentSessionAttachmentPathUploadResult = { + /** Input order; `path` is where the server stored the file. */ + uploaded: { sourcePath: string; path: string }[] + skipped: { sourcePath: string; reason: ImportSkipReason }[] + failed: { sourcePath: string; reason: string }[] +} + +/** Asks a clipboard image save to land in the chat's attachment store on the paired server named + * by the save's `runtimeEnvironmentId`, instead of that server's temp directory. */ +export type AgentSessionAttachmentClipboardTarget = Omit< + AgentSessionAttachmentUploadTarget, + 'environmentId' +> + +/** `orca-paste-…png`, the name every pasted image gets, so it reads as "Pasted image". */ +export function agentSessionPastedImageName(now: number, unique: string): string { + return `orca-paste-${now}-${unique}.png` +} diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 90cff98dd605..81124af85f6a 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -26,6 +26,8 @@ export const AGENT_SESSION_REFUSAL_REASONS = { 'messageIdReused', 'operationRefusedEarlier', 'journalWriteFailed', + /** The message names a chat attachment the host no longer stores. */ + 'attachmentExpired', // The conversation's state 'conversationCleared', /** Older hosts only: a /clear that never committed; its replacement may not exist. */ diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 9206ffd7a25c..e11950e73f75 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -66,6 +66,7 @@ const REASON_WORDS = { // Settled under that id, so the control's retry goes out under a new one. operationRefusedEarlier: codeWords('retry'), journalWriteFailed: causeWords('recordFailed', 'retry'), + attachmentExpired: causeWords('attachmentExpired', 'actFirst', 'reattachFile'), conversationCleared: causeWords( 'conversationCleared', 'goElsewhere', diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 3796de6eaf05..3e0e3277c036 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -44,6 +44,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { cannotRunHere: "Orca can't run this agent in a chat here.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't save this to the chat's history.", + attachmentExpired: 'This attachment expired.', + reattachFile: 'Remove it and attach it again.', conversationCleared: 'This conversation has been cleared.', openCurrentConversation: 'Open the current conversation to continue.', clearUnfinished: "The last /clear didn't finish.", diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 873b5773ebcf..f600123fd29f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -270,6 +270,10 @@ export const AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY = // `answers` to an older host; they fall back to the answer packed into `optionId`. export const AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY = 'agent-session.question-answers.v1' as const +// Why: a structured chat on a paired server stores attached files on that server +// (agentSessionAttachment.*); an older server has no store, so clients must refuse +// the attach rather than hand the agent a path from this machine. +export const AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY = 'agent-session.attachments.v1' as const // Why: the host now publishes rows for work that is live inside a turn, and such // a row carries `stoppable: false` because no targeted stop can reach it. A // reader that predates the field draws a per-row Stop on every row it is given, @@ -397,6 +401,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_ATTACHMENTS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/shared/rpc-contract/agent-session-attachment-params.ts b/src/shared/rpc-contract/agent-session-attachment-params.ts new file mode 100644 index 000000000000..0288c1e46323 --- /dev/null +++ b/src/shared/rpc-contract/agent-session-attachment-params.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' +import { + AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, + AGENT_SESSION_ATTACHMENT_MAX_BYTES +} from '../agent-session-attachments' +import { isValidBase64 } from './clipboard-params' +import { Identifier, SessionId } from './structured-agent-session-params' + +const UploadId = Identifier('Invalid upload id', 128) + +export const AttachmentUploadStartParams = z + .object({ + sessionId: SessionId, + name: z.string().min(1).max(1024), + byteLength: z + .number() + .int() + .nonnegative() + .max(AGENT_SESSION_ATTACHMENT_MAX_BYTES, 'Attachment is too large') + }) + .strict() + +export const AttachmentUploadAppendParams = z + .object({ + uploadId: UploadId, + offset: z.number().int().nonnegative(), + contentBase64: z + .string() + .max(AGENT_SESSION_ATTACHMENT_CHUNK_BASE64_CHARS, 'Attachment chunk is too large') + .refine(isValidBase64, 'Attachment chunk must be base64') + }) + .strict() + +export const AttachmentUploadIdParams = z.object({ uploadId: UploadId }).strict() + +export const AttachmentReadParams = z.object({ path: z.string().min(1).max(4096) }).strict() diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 5058efda5665..d425c20d38dc 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -41,6 +41,12 @@ import { } from './accounts-params' import { PrepareCodexForWslPaneParams } from './agent-hooks-params' import { AgentLaunch, AgentLaunchReplay } from './agent-launch-params' +import { + AttachmentReadParams, + AttachmentUploadAppendParams, + AttachmentUploadIdParams, + AttachmentUploadStartParams +} from './agent-session-attachment-params' import { CreateAgentSessionParams, EnsureAgentSessionParams } from './agent-session-params' import { ReadVisualParams } from './agent-session-visual-params' import { @@ -633,6 +639,11 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, 'agentSession.threadGoal': ThreadGoalParams, 'agentSession.unsubscribe': UnsubscribeParams, + 'agentSessionAttachment.read': AttachmentReadParams, + 'agentSessionAttachment.uploadAbort': AttachmentUploadIdParams, + 'agentSessionAttachment.uploadAppend': AttachmentUploadAppendParams, + 'agentSessionAttachment.uploadCommit': AttachmentUploadIdParams, + 'agentSessionAttachment.uploadStart': AttachmentUploadStartParams, 'agentTeams.prepareLaunch': AgentTeamsPrepareLaunch, 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat, 'aiVault.listSessions': AiVaultListSessionsParams, From 726eaf117e8606956051b218b8710e0fcb0c9b27 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:03:02 +0000 Subject: [PATCH 114/394] fix(native-chat): recall every sent prompt with Up/Down in the composer (#26044) * fix(native-chat): recall every sent prompt with Up/Down in the composer Recall read a list kept in the composer, so it forgot everything on reload or remount. It now reads the prompts the chat shows. * fix(native-chat): put the caret at the end of a recalled prompt It stayed where the last prompt had it, so Up skipped past a recalled multi-line prompt. * test(native-chat): read the editor in the recall spec through a type guard --- .../native-chat/NativeChatComposer.tsx | 26 ++- .../NativeChatContextUsageRing.test.tsx | 5 +- .../native-chat/NativeChatPromptEditor.tsx | 18 +++ .../native-chat/NativeChatResolvedView.tsx | 4 +- .../NativeChatStructuredSession.tsx | 1 + ...ive-chat-composer-draft-lifecycle.test.tsx | 1 - .../native-chat/native-chat-composer-input.ts | 3 + ...ative-chat-composer-prompt-recall.test.tsx | 151 ++++++++++++++++++ .../native-chat-composer-state.test.ts | 48 ------ .../native-chat/native-chat-composer-state.ts | 31 ---- .../native-chat/native-chat-composer-types.ts | 3 + .../native-chat-context-command.test.tsx | 2 - .../native-chat-sent-prompt-history.test.ts | 101 ++++++++++++ .../native-chat-sent-prompt-history.ts | 114 +++++++++++++ ...native-chat-submission-navigation.test.tsx | 12 +- ...tructured-session-takeover-report.test.tsx | 1 - .../use-native-chat-composer-catalog.test.tsx | 5 +- .../use-native-chat-composer-handle.ts | 3 +- ...ative-chat-composer-keydown.steer.test.tsx | 5 +- .../use-native-chat-composer-keydown.test.tsx | 128 ++++++++++++++- .../use-native-chat-composer-keydown.ts | 76 +++++---- .../use-native-chat-composer-recall.test.tsx | 25 +++ .../use-native-chat-composer-recall.ts | 59 +++++++ .../use-native-chat-composer-submit.test.tsx | 4 +- .../use-native-chat-composer-submit.ts | 15 +- .../use-native-chat-local-command-answer.ts | 3 - ...tive-chat-picker-command-dispatch.test.tsx | 2 - ...use-native-chat-picker-command-dispatch.ts | 11 +- ...use-native-chat-pty-composer-send.test.tsx | 1 - .../use-native-chat-pty-composer-send.ts | 3 - ...ative-chat-session-option-command.test.tsx | 3 +- .../use-native-chat-session-option-command.ts | 9 +- ...ive-chat-structured-composer-send.test.tsx | 1 - ...se-native-chat-structured-composer-send.ts | 5 - .../use-native-chat-typed-insertion.ts | 7 +- tests/e2e/native-chat-prompt-recall.spec.ts | 144 +++++++++++++++++ 36 files changed, 817 insertions(+), 213 deletions(-) create mode 100644 src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-sent-prompt-history.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-sent-prompt-history.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-recall.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-recall.ts create mode 100644 tests/e2e/native-chat-prompt-recall.spec.ts diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index a895159bba8f..1526f521a19b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -4,9 +4,9 @@ import { forwardRef, useCallback, useState } from 'react' import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt' import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { EMPTY_HISTORY, type HistoryState } from './native-chat-composer-state' import { useNativeChatMentionFiles } from './use-native-chat-mention-files' import { useNativeChatDraft } from './use-native-chat-draft' +import { useNativeChatComposerRecall } from './use-native-chat-composer-recall' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' import type { NativeChatResolvedTarget } from './native-chat-composer-target' @@ -71,6 +71,7 @@ const NativeChatComposerPane = forwardRef(EMPTY_HISTORY) const [activeSuggestion, setActiveSuggestion] = useState(0) const { notices, setNotice } = useNativeChatComposerNotice(chatNotices) const { textareaRef } = useNativeChatComposerAppMenuSelection(imeEnterGesture.isComposing) + const recall = useNativeChatComposerRecall({ + draft, + source: recallSource, + inputRef: textareaRef + }) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, targetPtyId, @@ -118,7 +123,7 @@ const NativeChatComposerPane = forwardRef { setDraft(value) - setHistory((prev) => ({ entries: prev.entries, index: null })) syncCaret(element) handleDraftOrCaretChange(value, element.selectionStart ?? value.length) setActiveSuggestion(0) diff --git a/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx b/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx index 290c66355b50..094feedf5baa 100644 --- a/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatContextUsageRing.test.tsx @@ -249,7 +249,6 @@ describe('NativeChatContextUsageRing', () => { it('spends an Escape typed in the composer on closing the card, not on stopping the agent', async () => { const { NativeChatContextUsageRing } = await import('./NativeChatContextUsageRing') const { useNativeChatComposerKeyDown } = await import('./use-native-chat-composer-keydown') - const { EMPTY_HISTORY } = await import('./native-chat-composer-state') const interrupt = vi.fn() function ComposerWithRing(): React.JSX.Element { const onKeyDown = useNativeChatComposerKeyDown({ @@ -258,7 +257,6 @@ describe('NativeChatContextUsageRing', () => { completeMention: vi.fn(), activeSuggestion: 0, draft: '', - history: EMPTY_HISTORY, isComposing: () => false, completePickerItem: vi.fn(), dispatchPickerCommand: vi.fn(), @@ -267,8 +265,7 @@ describe('NativeChatContextUsageRing', () => { send: vi.fn(), setActiveSuggestion: vi.fn(), setDraft: vi.fn(), - setCaret: vi.fn(), - setHistory: vi.fn() + setCaret: vi.fn() }) return ( <> diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index 0f02869acb4d..d2655c0ceaf5 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -222,6 +222,24 @@ export function NativeChatPromptEditor({ editor.view.dom.focus() }, contains: (node) => editor.view.dom.contains(node), + isCaretOnVisualEdge: (edge) => { + const { selection, doc } = editor.state + if (!selection.empty) { + return false + } + const { text } = promptTextMap(doc) + // An empty composer has one line: no layout read on the path every recall starts from. + if (text === '') { + return true + } + const caret = promptTextOffset(doc, selection.from) + const beyond = edge === 'start' ? text.slice(0, caret) : text.slice(caret) + // Why both: the text check covers other paragraphs, the view covers soft wraps. + return ( + !beyond.includes('\n') && + editor.view.endOfTextblock(edge === 'start' ? 'up' : 'down') + ) + }, select: () => { editor.commands.selectAll() }, diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 9d8444dc73a0..44104bfa3a2c 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -236,8 +236,7 @@ export function NativeChatResolvedView({ canSend, transcriptSettled: session.readPhase === 'ready' }) - const shownPromptCard = promptCardPresentation.card - const collapsedCard = promptCardPresentation.collapsedCard + const { card: shownPromptCard, collapsedCard } = promptCardPresentation const mountedPromptCard = shownPromptCard ?? collapsedCard useNativeChatComposerRevealFocus({ rootRef, @@ -441,6 +440,7 @@ export function NativeChatResolvedView({ onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }} + recallSource={{ messages: sessionWithPending.messages, commands: commandMarkers }} />
{contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b8926ec61770..b1db95d3f6f2 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -352,6 +352,7 @@ export function NativeChatStructuredSession( structuredTransport={structuredTransport} launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} notices={notices} + recallSource={{ messages: session.messages }} /> ) : null} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx index 4e93db226d93..5c6ac2d12c2c 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-draft-lifecycle.test.tsx @@ -158,7 +158,6 @@ function composer( structuredTransport: transport, isComposing: () => false, clearSkillOrigin: () => {}, - setHistory: () => {}, setDraft, setCaret }) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-input.ts b/src/renderer/src/components/native-chat/native-chat-composer-input.ts index 91d9be943d7d..f4483d7575b0 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-input.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-input.ts @@ -12,6 +12,9 @@ export type NativeChatComposerInput = Pick< contains?: (node: Node | null) => boolean insertText?: (text: string) => void insertSkill?: (from: number, to: number, token: string) => void + /** Whether a collapsed caret sits on the first (`start`) or last (`end`) visual line, + * counting wrapped lines, so an arrow key there has nowhere left to move it. */ + isCaretOnVisualEdge?: (edge: 'start' | 'end') => boolean } export function insertNativeChatPastedText( diff --git a/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx new file mode 100644 index 000000000000..368da645dc9b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-composer-prompt-recall.test.tsx @@ -0,0 +1,151 @@ +import { changePrompt, promptValue } from './native-chat-prompt-editor.test-support' +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) +vi.mock('./NativeChatComposerActions', () => ({ + NativeChatComposerActions: () =>
+})) +vi.mock('./NativeChatAutocompleteMenus', () => ({ + NativeChatMentionHint: () => null, + NativeChatPickerMenu: () => null +})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + agentStatusByPaneKey: {}, + updateSettings: vi.fn(), + clearNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchDraftAdopted: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown): unknown => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: () => ({}) +})) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: vi.fn(), + sendNativeChatTypedCommand: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-runtime-image-send', () => ({ + sendNativeChatMessageWithImageAttachments: vi.fn() +})) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: vi.fn() +})) +vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({ + ...(await importOriginal()), + getVerifiedNativeChatCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() +})) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) + +import { NativeChatComposer } from './NativeChatComposer' + +const sent = (id: string, text: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' +}) + +let paneCounter = 0 + +/** The real composer, editor and draft store over a chat that already holds `prompts`. */ +function renderComposer(prompts: string[]): HTMLElement { + paneCounter += 1 + render( + true), + dispatchCommand: vi.fn(async () => ({ handled: false, accepted: false, error: null })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + onError: vi.fn(), + runtime: 'local', + sessionId: `session-${paneCounter}`, + runtimeEnvironmentId: null + }} + recallSource={{ messages: prompts.map((text, index) => sent(`sent-${index}`, text)) }} + /> + ) + return screen.getByRole('textbox') +} + +function press(input: HTMLElement, key: 'ArrowUp' | 'ArrowDown'): string { + fireEvent.keyDown(input, { key }) + return promptValue(input) +} + +beforeEach(() => { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + git: { discoverCommitMessageModels: vi.fn().mockResolvedValue({ success: false }) }, + pty: { getMainBufferSnapshot: vi.fn().mockResolvedValue(null) }, + ui: { onFileDrop: () => vi.fn() } + } + }) +}) + +afterEach(() => cleanup()) + +describe('composer prompt recall', () => { + it('walks prompts sent before this composer existed, and back to an empty draft', () => { + const input = renderComposer(['one', 'two']) + expect(press(input, 'ArrowUp')).toBe('two') + expect(press(input, 'ArrowUp')).toBe('one') + expect(press(input, 'ArrowDown')).toBe('two') + expect(press(input, 'ArrowDown')).toBe('') + }) + + // `/model` opens the command picker when typed; recalled, it must not take the arrows. + it('keeps walking past a recalled command instead of handing the arrows to its picker', () => { + const input = renderComposer(['one', '/model']) + expect(press(input, 'ArrowUp')).toBe('/model') + expect(press(input, 'ArrowUp')).toBe('one') + }) + + it('leaves the arrows alone once a recalled prompt is edited', () => { + const input = renderComposer(['one', 'two']) + press(input, 'ArrowUp') + changePrompt(input, 'two, edited') + expect(press(input, 'ArrowUp')).toBe('two, edited') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 57d7b876b9ae..524ffe12256d 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -7,13 +7,9 @@ import { classifyNativeChatSend, deriveComposerAutocomplete, editReplacesTriggerToken, - EMPTY_HISTORY, filterSlashCommands, isSkillPickerTriggered, isSlashCommandDraft, - pushHistory, - recallNext, - recallPrevious, slashCommandDispatchText, type SlashCommandSuggestion } from './native-chat-composer-state' @@ -145,50 +141,6 @@ describe('isSlashCommandDraft', () => { }) }) -describe('history recall', () => { - it('up-arrow on empty composer recalls the last sent input', () => { - const history = pushHistory(EMPTY_HISTORY, 'first') - const recall = recallPrevious(history) - expect(recall.draft).toBe('first') - expect(recall.history.index).toBe(0) - }) - - it('walks backward and clamps at the oldest entry', () => { - let history = pushHistory(EMPTY_HISTORY, 'a') - history = pushHistory(history, 'b') - const first = recallPrevious(history) - expect(first.draft).toBe('b') - const second = recallPrevious(first.history) - expect(second.draft).toBe('a') - const third = recallPrevious(second.history) - expect(third.draft).toBe('a') // clamped - }) - - it('down-arrow walks forward and returns to a live empty draft', () => { - let history = pushHistory(EMPTY_HISTORY, 'a') - history = pushHistory(history, 'b') - const up1 = recallPrevious(history) // 'b' - const up2 = recallPrevious(up1.history) // 'a' - const down = recallNext(up2.history) // 'b' - expect(down.draft).toBe('b') - const back = recallNext(down.history) // live - expect(back.draft).toBe('') - expect(back.history.index).toBeNull() - }) - - it('does not record blank sends or immediate duplicates', () => { - let history = pushHistory(EMPTY_HISTORY, ' ') - expect(history.entries).toHaveLength(0) - history = pushHistory(history, 'x') - history = pushHistory(history, 'x') - expect(history.entries).toHaveLength(1) - }) - - it('recall on empty history is a no-op', () => { - expect(recallPrevious(EMPTY_HISTORY).draft).toBeNull() - }) -}) - describe('apply suggestions', () => { it('applySlashSuggestion replaces the token with a trailing space', () => { expect(applySlashSuggestion({ name: 'clear' })).toBe('/clear ') diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index 59fbcf45d099..abee40a1f010 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -203,34 +203,3 @@ export function applyMentionSuggestion( const nextBefore = `${before.slice(0, tokenStart)}${formatNativeChatFileReference(path)} ` return { draft: nextBefore + after.replace(/^ /, ''), caret: nextBefore.length } } - -export type HistoryState = { entries: readonly string[]; index: number | null } -export const EMPTY_HISTORY: HistoryState = { entries: [], index: null } - -export function pushHistory(history: HistoryState, sent: string): HistoryState { - if (sent.trim() === '' || history.entries.at(-1) === sent) { - return { entries: history.entries, index: null } - } - return { entries: [...history.entries, sent], index: null } -} - -export type HistoryRecall = { history: HistoryState; draft: string | null } - -export function recallPrevious(history: HistoryState): HistoryRecall { - if (history.entries.length === 0) { - return { history, draft: null } - } - const index = history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1) - return { history: { entries: history.entries, index }, draft: history.entries[index] } -} - -export function recallNext(history: HistoryState): HistoryRecall { - if (history.index === null) { - return { history, draft: null } - } - const index = history.index + 1 - if (index >= history.entries.length) { - return { history: { entries: history.entries, index: null }, draft: '' } - } - return { history: { entries: history.entries, index }, draft: history.entries[index] } -} diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 03bdc73a7109..830a301d144b 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -13,6 +13,7 @@ import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' import type { NativeChatAfterStopSend } from './native-chat-composer-target' import type { NativeChatLocalCommandAnswer } from './use-native-chat-local-command-answer' +import type { NativeChatRecallSource } from './native-chat-sent-prompt-history' export type NativeChatOptionPickerRequest = { id: string @@ -120,6 +121,8 @@ export type NativeChatComposerProps = { steerQueued?: () => boolean /** The chat's own notices, shown in the composer's notice card above its input. */ notices?: readonly NativeChatComposerNotice[] + /** The conversation Up/Down recalls prompts from. */ + recallSource?: NativeChatRecallSource } /** Launch context prefilled into the TUI input as an unsent draft, plus the two diff --git a/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx b/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx index 3630860d0a77..0888be316d27 100644 --- a/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-context-command.test.tsx @@ -195,7 +195,6 @@ describe('host-answered /context in the composer', () => { answerCommandLocally, sessionOptionsSurface: liveSurface(DISCOVERED), trackPendingSend: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn(), clearSkillOrigin: vi.fn(), @@ -256,6 +255,5 @@ describe('host-answered /context in the composer', () => { expect(args.onSlashCommand).toHaveBeenCalledWith('/context', 'Context: 450k / 1M tokens (45%)') expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() expect(args.clearImageAttachments).not.toHaveBeenCalled() - expect(args.setHistory).toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-sent-prompt-history.test.ts b/src/renderer/src/components/native-chat/native-chat-sent-prompt-history.test.ts new file mode 100644 index 000000000000..9115732fd2da --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-sent-prompt-history.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + nativeChatSentPrompts, + stepNativeChatPromptRecall +} from './native-chat-sent-prompt-history' + +function message( + id: string, + role: NativeChatMessage['role'], + text: string, + extra: Partial = {} +): NativeChatMessage { + return { + id, + role, + blocks: text ? [{ type: 'text', text }] : [], + timestamp: null, + source: 'transcript', + ...extra + } +} + +describe('nativeChatSentPrompts', () => { + it('lists only prompts the user typed, oldest first', () => { + const prompts = nativeChatSentPrompts({ + messages: [ + message('1', 'user', 'first'), + message('2', 'assistant', 'reply'), + message('3', 'user', 'from a peer', { + from: { kind: 'agent', senders: [], orchestration: null } + }), + message('4', 'user', ''), + message('6', 'user', 'done'), + message('7', 'user', 'prompt a parent agent gave its subagent', { agentId: 'sub-1' }), + message('5', 'user', 'second\n\nwith a paragraph ') + ] + }) + expect(prompts).toEqual([ + { id: '1', prompt: 'first' }, + { id: '5', prompt: 'second\n\nwith a paragraph' } + ]) + }) + + it('collapses a prompt sent back to back into its newest copy', () => { + const prompts = nativeChatSentPrompts({ + messages: [ + message('1', 'user', 'continue'), + message('2', 'assistant', 'ok'), + message('3', 'user', 'continue') + ] + }) + expect(prompts).toEqual([{ id: '3', prompt: 'continue' }]) + }) + + it('places a command the transcript holds no turn for by when it was sent', () => { + const prompts = nativeChatSentPrompts({ + messages: [ + message('1', 'user', 'before', { timestamp: 100 }), + message('2', 'user', 'after', { timestamp: 300 }) + ], + commands: [ + { id: 'a', command: '/model opus', sentAt: 200 }, + { id: 'b', command: '/compact', sentAt: 400 } + ] + }) + expect(prompts.map((entry) => entry.prompt)).toEqual([ + 'before', + '/model opus', + 'after', + '/compact' + ]) + }) +}) + +describe('stepNativeChatPromptRecall', () => { + const prompts = [ + { id: '1', prompt: 'one' }, + { id: '2', prompt: 'two' } + ] + + it('keeps its place when the recalled echo is replaced by its transcript turn', () => { + const step = stepNativeChatPromptRecall({ + direction: 'back', + prompts, + position: { id: 'pending:9', recalled: 'two' }, + draft: 'two' + }) + expect(step?.draft).toBe('one') + }) + + it('keeps its place when a new prompt lands mid-recall', () => { + const step = stepNativeChatPromptRecall({ + direction: 'back', + prompts: [...prompts, { id: '3', prompt: 'from another client' }], + position: { id: '2', recalled: 'two' }, + draft: 'two' + }) + expect(step?.draft).toBe('one') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-sent-prompt-history.ts b/src/renderer/src/components/native-chat/native-chat-sent-prompt-history.ts new file mode 100644 index 000000000000..facbc7557545 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-sent-prompt-history.ts @@ -0,0 +1,114 @@ +// Terminal-style prompt recall: Up/Down in the composer walk what the chat shows the +// user sent. Derived from the shown conversation at the keypress, so there is no +// history to store, and recall survives a remount, a reload, and another client's sends. + +import { deriveNativeChatRowContent } from '../../../../shared/native-chat-row-content' +import { projectNativeChatTranscriptMessages } from '../../../../shared/native-chat-transcript-projection' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { NativeChatCommandMarker } from './native-chat-command-marker' +import { compareMessages } from './native-chat-session-assembler' + +/** The conversation a composer recalls from. */ +export type NativeChatRecallSource = { + messages: readonly NativeChatMessage[] + /** Commands sent from this pane that the transcript holds no user turn for. */ + commands?: readonly NativeChatCommandMarker[] +} + +export type NativeChatSentPrompt = { id: string; prompt: string } + +/** Everything the composer's keydown needs to recall; absent leaves the arrows to the caret. */ +export type NativeChatComposerRecall = { + source: NativeChatRecallSource + position: NativeChatRecallPosition | null + setPosition: (position: NativeChatRecallPosition | null) => void + /** False keeps an arrow key moving the caret inside a recalled multi-line prompt. */ + isCaretOnVisualEdge: (edge: 'start' | 'end') => boolean + /** Puts a recalled prompt in the editor with the caret at its end, as a shell does. Done at + * the keypress: left to the draft sync, the caret stays wherever the last prompt had it. */ + show: (prompt: string) => void +} + +/** `id` survives new messages landing mid-recall; `recalled` is the text put in the + * composer, and recall is over once the composer no longer holds it. */ +export type NativeChatRecallPosition = { id: string; recalled: string } + +/** Recall is live only while the composer still holds the recalled text untouched. */ +export function isNativeChatRecallActive( + position: NativeChatRecallPosition | null, + draft: string +): position is NativeChatRecallPosition { + return position !== null && position.recalled === draft +} + +/** Oldest first. Text only: an images-only send has nothing to recall. */ +export function nativeChatSentPrompts(source: NativeChatRecallSource): NativeChatSentPrompt[] { + const prompts: NativeChatSentPrompt[] = [] + const push = (id: string, text: string): void => { + const prompt = text.trim() + if (prompt === '') { + return + } + // A prompt repeated back to back is one stop, as in a shell's history. + if (prompts.at(-1)?.prompt === prompt) { + prompts.pop() + } + prompts.push({ id, prompt }) + } + const commands = source.commands ?? [] + let nextCommand = 0 + // Why projected: the raw list also holds harness-injected user turns and subagent prompts, + // which the chat never draws as the user's own. + for (const message of projectNativeChatTranscriptMessages(source.messages, compareMessages)) { + // `from` marks a prompt another agent sent; it was never typed here. + if (message.role !== 'user' || message.from) { + continue + } + while ( + message.timestamp !== null && + nextCommand < commands.length && + commands[nextCommand].sentAt < message.timestamp + ) { + const command = commands[nextCommand++] + push(`command:${command.id}`, command.command) + } + push(message.id, deriveNativeChatRowContent(message.blocks).markdown) + } + for (const command of commands.slice(nextCommand)) { + push(`command:${command.id}`, command.command) + } + return prompts +} + +/** + * One Up (`back`) or Down (`forward`) press. Null leaves the key to the caret. Back starts + * only from an empty composer and stops at the oldest prompt; forward past the newest + * empties the composer and ends recall. + */ +export function stepNativeChatPromptRecall(input: { + direction: 'back' | 'forward' + prompts: readonly NativeChatSentPrompt[] + position: NativeChatRecallPosition | null + draft: string +}): { position: NativeChatRecallPosition | null; draft: string } | null { + const { prompts, position, draft } = input + let active = -1 + if (isNativeChatRecallActive(position, draft)) { + active = prompts.findIndex((prompt) => prompt.id === position.id) + // An echo replaced by its transcript turn changes id but not text. + if (active < 0) { + active = prompts.findLastIndex((prompt) => prompt.prompt === position.recalled) + } + } + if (active < 0 && (input.direction === 'forward' || draft !== '')) { + return null + } + const next = + input.direction === 'forward' + ? prompts[active + 1] + : prompts[active < 0 ? prompts.length - 1 : active - 1] + if (next) { + return { position: { id: next.id, recalled: next.prompt }, draft: next.prompt } + } + return input.direction === 'forward' ? { position: null, draft: '' } : null +} diff --git a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx index a93b7b3671c2..b1c1287a9360 100644 --- a/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-submission-navigation.test.tsx @@ -155,7 +155,6 @@ it('reveals a structured command at the press, before the host answers it', asyn structuredTransport: t, isComposing: () => false, clearSkillOrigin: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn() }) @@ -193,7 +192,6 @@ it('reveals a goal at the press, before the host sets it', async () => { disabled: false, sendPty: vi.fn(), sendStructured: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn() }) @@ -229,7 +227,6 @@ it.each(['/model', '/effort'])( structuredTransport: t, isComposing: () => false, clearSkillOrigin: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn() }) @@ -251,7 +248,6 @@ function localAnswerArgs() { answerCommandLocally: () => 'Context: 20k / 272k tokens', sessionOptionsSurface: null, trackPendingSend: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn(), clearSkillOrigin: vi.fn(), @@ -306,8 +302,7 @@ it('reveals only the pane whose option send it was, at the press', async () => { agent: 'codex', disabled: false, resolveTarget: () => ({ ptyId, settings: null }), - onSubmitted: reveal.revealLatest, - setHistory: vi.fn() + onSubmitted: reveal.revealLatest }) }, { initialProps: { ptyId: 'pty-a' } } @@ -336,8 +331,7 @@ it('reveals a PTY option send at the press, before the terminal confirms it', as agent: 'codex', disabled: false, resolveTarget: () => ({ ptyId: 'pty-a', settings: null }), - onSubmitted: transcript.revealLatest, - setHistory: vi.fn() + onSubmitted: transcript.revealLatest }) return { ...transcript, command } }) @@ -370,7 +364,6 @@ it('control: a refused structured message does not reveal', async () => { structuredTransport: t, isComposing: () => false, clearSkillOrigin: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn() }) @@ -449,7 +442,6 @@ it('keeps an accepted queued draft out of transcript navigation', async () => { structuredTransport: t, isComposing: () => false, clearSkillOrigin: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn() }) diff --git a/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx index e684899737d0..6a1ba6d6e29d 100644 --- a/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx +++ b/src/renderer/src/components/native-chat/structured-session-takeover-report.test.tsx @@ -55,7 +55,6 @@ function send(structuredTransport: NativeChatStructuredComposerTransport): (text structuredTransport, isComposing: () => false, clearSkillOrigin: vi.fn(), - setHistory: vi.fn(), setDraft: vi.fn(), setCaret: vi.fn() }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx index 707a800f343a..1b89fb06bbed 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-catalog.test.tsx @@ -3,7 +3,6 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { buildNativeChatPickerItems } from './native-chat-picker-items' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' -import { EMPTY_HISTORY } from './native-chat-composer-state' import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog' import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' @@ -133,7 +132,6 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co completeMention: vi.fn(), activeSuggestion, draft: '/', - history: EMPTY_HISTORY, isComposing: () => false, completePickerItem: complete, dispatchPickerCommand: dispatch, @@ -142,8 +140,7 @@ it('Enter completes a known pre-init skill while still dispatching a built-in co send: vi.fn(), setActiveSuggestion: vi.fn(), setDraft: vi.fn(), - setCaret: vi.fn(), - setHistory: vi.fn() + setCaret: vi.fn() }) }, { initialProps: { activeSuggestion: 1 } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts index fb7a704d6556..a21cd58e7cd5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts @@ -16,14 +16,13 @@ export function useNativeChatComposerHandle( ref: ForwardedRef, args: UseNativeChatComposerHandleArgs ): (event: ClipboardEventLike) => void { - const { textareaRef, draft, setDraft, setHistory, setActiveSuggestion, ...pasteArgs } = args + const { textareaRef, draft, setDraft, setActiveSuggestion, ...pasteArgs } = args const { insertTypedText, insertPastedText, focus, contains } = useNativeChatTypedInsertion({ textareaRef, caret: args.caret, draft, setDraft, setCaret: args.setCaret, - setHistory, setActiveSuggestion }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx index 0f15584e92c4..29ce20486af6 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.steer.test.tsx @@ -7,7 +7,6 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { EMPTY_HISTORY } from './native-chat-composer-state' const platform = vi.hoisted(() => ({ isMac: true })) vi.mock('./native-chat-shortcut', () => ({ @@ -28,8 +27,7 @@ function setup( send: vi.fn(), setActiveSuggestion: vi.fn(), setDraft: vi.fn(), - setCaret: vi.fn(), - setHistory: vi.fn() + setCaret: vi.fn() } const hook = renderHook(() => useNativeChatComposerKeyDown({ @@ -39,7 +37,6 @@ function setup( activeSuggestion: 0, draft: composer.draft ?? '', hasAttachments: composer.hasAttachments ?? false, - history: EMPTY_HISTORY, isComposing: () => false, ...(steerQueued ? { steerQueued } : {}), ...callbacks diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx index 42a4be495e01..8c9f6aa1531b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -1,15 +1,16 @@ // @vitest-environment happy-dom import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' -import type { KeyboardEventHandler } from 'react' +import { useState, type KeyboardEventHandler } from 'react' import { describe, expect, it, vi } from 'vitest' import { applyPickerSuggestion, deriveComposerAutocomplete, - EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { useNativeChatRecallPosition } from './use-native-chat-composer-recall' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' const COMMAND = { @@ -55,8 +56,7 @@ function setup( send: vi.fn(), setActiveSuggestion: vi.fn(), setDraft: vi.fn(), - setCaret: vi.fn(), - setHistory: vi.fn() + setCaret: vi.fn() } const hook = renderHook(() => useNativeChatComposerKeyDown({ @@ -64,7 +64,6 @@ function setup( mentionFiles: { files, loading, failed: false }, activeSuggestion, draft, - history: EMPTY_HISTORY, isComposing: () => composing, ...callbacks }) @@ -199,4 +198,123 @@ describe('useNativeChatComposerKeyDown', () => { expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled() expect(callbacks.send).not.toHaveBeenCalled() }) + + describe('prompt recall', () => { + const userMessage = (id: string, text: string): NativeChatMessage => ({ + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + }) + + const onEveryEdge = (): boolean => true + + function Composer({ + prompts, + initialDraft = '', + hasAttachments, + isCaretOnVisualEdge = onEveryEdge + }: { + prompts: string[] + initialDraft?: string + hasAttachments?: boolean + isCaretOnVisualEdge?: (edge: 'start' | 'end') => boolean + }): React.JSX.Element { + const [draft, setDraft] = useState(initialDraft) + const [recallPosition, setRecallPosition] = useNativeChatRecallPosition(draft) + const onKeyDown = useNativeChatComposerKeyDown({ + autocomplete: { mode: 'none' }, + activeSuggestion: 0, + draft, + mentionFiles: { files: [], loading: false, failed: false }, + completeMention: vi.fn(), + recall: { + source: { messages: prompts.map((text, index) => userMessage(String(index), text)) }, + position: recallPosition, + setPosition: setRecallPosition, + isCaretOnVisualEdge, + show: vi.fn() + }, + isComposing: () => false, + completePickerItem: vi.fn(), + dispatchPickerCommand: vi.fn(), + dismissPicker: vi.fn(), + interrupt: vi.fn(), + send: vi.fn(), + setActiveSuggestion: vi.fn(), + setDraft, + setCaret: vi.fn(), + ...(hasAttachments === undefined ? {} : { hasAttachments }) + }) + return ( +