diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 17a9566b9b7..8da01ca1f67 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -229,6 +229,7 @@ import { TerminalPaneView } from '../../../../src/session/TerminalPaneView' import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay' import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView' import { MobileStructuredSessionCreateError } from '../../../../src/session/MobileStructuredSessionCreateError' +import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send' import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry' import { isMobileStructuredAgent, @@ -4671,6 +4672,31 @@ export default function SessionScreen() { } return accepted }} + onTuiSend={async (text, restored) => { + const terminal = mobileStructuredTuiSend.getMobileStructuredTuiTerminal( + structuredSessionEntry.session.handoff + ) + if (!client || connState !== 'connected' || !terminal) { + showToast('Message not sent (disconnected)', 1800) + return false + } + const outcome = await mobileStructuredTuiSend.sendMobileStructuredTuiMessage({ + client, + terminal, + deviceToken: deviceTokenRef.current, + text, + attachments: [...restored, ...structuredSessionEntry.attachments.attachments] + }) + if (outcome === 'rejected') { + showToast('Message not sent', 1800) + return false + } + structuredSessionEntry.attachments.clear() + if (outcome === 'unknown') { + showToast('Delivery unconfirmed — check chat before retrying', 2400) + } + return true + }} onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit} onRetry={structuredSessionEntry.writes.retry} onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt} @@ -4680,6 +4706,8 @@ export default function SessionScreen() { onAttachImage={() => void structuredSessionEntry.attachments.attach('library')} onRemoveAttachment={structuredSessionEntry.attachments.remove} onCancel={structuredSessionEntry.writes.cancel} + handoff={structuredSessionEntry.session.handoff} + onRequestHandoff={structuredSessionEntry.writes.requestHandoff} /> ) : activePendingTerminalTab ? ( diff --git a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx index e912e8f0c1e..1bc33da31ec 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, type ComponentProps } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options' @@ -50,35 +50,49 @@ function options(): MobileNativeChatSessionOptionsController { describe('MobileStructuredAgentSessionView command seam', () => { let renderer: ReactTestRenderer | null = null const onSend = vi.fn(async () => true) + const onTuiSend = vi.fn(async () => true) let controller: MobileNativeChatSessionOptionsController + function props( + overrides: Partial> = {} + ): ComponentProps { + return { + agent: 'codex', + items: [], + status: 'ready', + hasOlder: false, + loadingOlder: false, + onLoadOlder: async () => false, + outbox: [], + onSend, + onTuiSend, + onTakeQueuedForEdit: async () => null, + onRetry: async () => {}, + onRespondToPrompt: async () => true, + sessionOptions: controller, + attachments: [], + isAttaching: false, + onAttachImage: () => {}, + onRemoveAttachment: () => {}, + onCancel: async () => true, + handoff: null, + onRequestHandoff: async () => true, + ...overrides + } + } + + function render(overrides = {}): void { + act(() => { + renderer = create(createElement(MobileStructuredAgentSessionView, props(overrides))) + }) + } + beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true onSend.mockClear() + onTuiSend.mockClear() controller = options() - act(() => { - renderer = create( - createElement(MobileStructuredAgentSessionView, { - agent: 'codex', - items: [], - status: 'ready', - hasOlder: false, - loadingOlder: false, - onLoadOlder: async () => false, - outbox: [], - onSend, - onTakeQueuedForEdit: async () => null, - onRetry: async () => {}, - onRespondToPrompt: async () => true, - sessionOptions: controller, - attachments: [], - isAttaching: false, - onAttachImage: () => {}, - onRemoveAttachment: () => {}, - onCancel: async () => true - }) - ) - }) + render() }) afterEach(() => { @@ -112,12 +126,7 @@ describe('MobileStructuredAgentSessionView command seam', () => { it('uses Claude branding and command suggestions for Claude sessions', () => { act(() => { - renderer!.update( - createElement(MobileStructuredAgentSessionView, { - ...renderer!.root.findByType(MobileStructuredAgentSessionView).props, - agent: 'claude' - }) - ) + renderer!.update(createElement(MobileStructuredAgentSessionView, props({ agent: 'claude' }))) }) const composer = renderer!.root.findByType('MobileNativeChatComposer') @@ -134,4 +143,88 @@ describe('MobileStructuredAgentSessionView command seam', () => { const empty = renderer!.root.findByType('FlatList').props.ListEmptyComponent expect(empty.props.children[0].props.children).toBe('New Claude chat') }) + + it('keeps the composer enabled and routes stable TUI ownership through its bridge', async () => { + act(() => { + renderer!.update( + createElement( + MobileStructuredAgentSessionView, + props({ + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' } + } + }) + ) + ) + }) + + expect(renderer!.root.findByType('MobileNativeChatComposer').props.disabled).toBe(false) + await submit('hello from mobile') + expect(onTuiSend).toHaveBeenCalledWith('hello from mobile', []) + expect(onSend).not.toHaveBeenCalled() + }) + + it('routes stable TUI slash commands to typed terminal dispatch', async () => { + act(() => { + renderer!.update( + createElement( + MobileStructuredAgentSessionView, + props({ + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' } + } + }) + ) + ) + }) + + await submit('/model') + expect(onTuiSend).toHaveBeenCalledWith('/model', []) + expect(controller.invokeAction).not.toHaveBeenCalled() + expect(onSend).not.toHaveBeenCalled() + }) + + it('disables and refuses composer sends while ownership is switching', async () => { + act(() => { + renderer!.update( + createElement( + MobileStructuredAgentSessionView, + props({ + handoff: { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'handoff-1' + } + }) + ) + ) + }) + + const composer = renderer!.root.findByType('MobileNativeChatComposer') + expect(composer.props.disabled).toBe(true) + await act(async () => { + expect(await composer.props.onSend('racing send')).toBe(false) + }) + expect(onSend).not.toHaveBeenCalled() + expect(onTuiSend).not.toHaveBeenCalled() + }) + + it('keeps native ownership on the structured agent-session send path', async () => { + await submit('native message') + + expect(onSend).toHaveBeenCalledWith('native message', []) + expect(onTuiSend).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/MobileStructuredAgentSessionView.tsx b/mobile/src/session/MobileStructuredAgentSessionView.tsx index 8165e7b0d2b..685e5aba7a4 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.tsx @@ -11,6 +11,11 @@ import { } from 'react-native' import { Square } from 'lucide-react-native' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffStatus +} from '../../../src/shared/agent-session-wire' import { colors } from '../theme/mobile-theme' import { MobileNativeChatComposer } from './MobileNativeChatComposer' import { MobileNativeChatMessage } from './MobileNativeChatMessage' @@ -56,6 +61,7 @@ type Props = { onOpenFile?: (path: string) => void outbox: MobileStructuredOutboxEntry[] onSend: (text: string, restored: readonly PendingNativeChatImage[]) => Promise + onTuiSend?: (text: string, restored: readonly PendingNativeChatImage[]) => Promise onTakeQueuedForEdit: (clientMessageId: string) => Promise onRetry: (clientMessageId: string) => Promise onRespondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise @@ -65,6 +71,12 @@ type Props = { onAttachImage: () => void onRemoveAttachment: (id: string) => void onCancel: (turnId: string) => Promise + handoff: AgentSessionHandoffStatus | null + onRequestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' + ) => Promise } export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element { @@ -79,6 +91,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen const allAttachments = [...restored, ...props.attachments] const agentName = props.agent === 'claude' ? 'Claude' : 'Codex' const slashCommands = useMemo(() => mobileStructuredSlashCommands(props.agent), [props.agent]) + const stableTuiOwner = + props.handoff?.owner === 'tui' && + props.handoff.phase === 'idle' && + Boolean(props.handoff.terminal?.handle) && + Boolean(props.onTuiSend) + const stableNativeOwner = + props.handoff === null || + props.handoff === undefined || + (props.handoff.owner === 'native' && props.handoff.phase === 'idle') + const composerEnabled = props.status === 'ready' && (stableNativeOwner || stableTuiOwner) const rows = useMemo( () => buildMobileStructuredTimeline(props.items, props.outbox), [props.items, props.outbox] @@ -107,6 +129,11 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen return ( + { + if (!composerEnabled) { + setCommandError('Session ownership is changing. Try again when switching finishes.') + return false + } if (allAttachments.length > 0 && isMobileStructuredComposerCommand(text, props.agent)) { setCommandError('Remove attachments before using a chat-session command.') return false } + if (stableTuiOwner) { + setCommandError(null) + const accepted = await props.onTuiSend!(text, restored) + if (accepted) { + setComposerText('') + setRestored([]) + } + return accepted + } const command = await dispatchMobileStructuredComposerCommand( text, props.sessionOptions, @@ -247,9 +287,101 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen } }} isAttaching={props.isAttaching} - disabled={props.status !== 'ready'} - placeholder={props.status === 'ready' ? `Message ${agentName}` : 'Reconnecting…'} + disabled={!composerEnabled} + placeholder={ + props.handoff?.owner === 'tui' && !stableTuiOwner + ? 'Agent is open in terminal' + : props.status === 'ready' + ? `Message ${agentName}` + : 'Reconnecting…' + } /> ) } + +function MobileStructuredHandoffBanner(props: { + status: AgentSessionHandoffStatus | null + isWorking: boolean + onRequest: Props['onRequestHandoff'] +}): React.JSX.Element | null { + const status = props.status + if (!status) { + return null + } + if (status.owner === 'native' && status.phase === 'idle') { + return ( + + {props.isWorking ? ( + <> + [styles.handoffButton, pressed && styles.pressed]} + onPress={() => void props.onRequest('to-tui', 'after-turn')} + > + Switch after this turn + + [styles.handoffButton, pressed && styles.pressed]} + onPress={() => void props.onRequest('to-tui', 'stop-turn')} + > + Stop turn and switch + + + ) : ( + [styles.handoffButton, pressed && styles.pressed]} + onPress={() => void props.onRequest('to-tui', 'now')} + > + Open agent TUI + + )} + + ) + } + const copy = + status.phase === 'waiting-for-exit' + ? 'Exit the agent terminal to continue in chat.' + : status.phase === 'switching' + ? mobileHandoffStageCopy(status) + : (status.error?.message ?? + `Agent is open in terminal${status.hostLabel ? ` on ${status.hostLabel}` : ''}`) + return ( + + {copy} + {status.phase === 'queued' && status.direction ? ( + void props.onRequest(status.direction!, 'after-turn', 'cancel-queued')} + > + Cancel + + ) : status.owner === 'tui' && status.phase === 'idle' ? ( + void props.onRequest('to-native', 'after-turn')}> + Return to chat + + ) : status.phase === 'failed' && + status.direction && + status.error?.recoverableOwner !== 'none' ? ( + void props.onRequest(status.direction!, 'now', 'retry')}> + Retry + + ) : null} + + ) +} + +function mobileHandoffStageCopy(status: AgentSessionHandoffStatus): string { + switch (status.stage) { + case 'preparing': + return status.direction === 'to-tui' ? 'Finishing chat session…' : 'Finishing agent terminal…' + case 'old-owner-stopped': + return status.direction === 'to-tui' ? 'Opening agent terminal…' : 'Resuming chat session…' + case 'new-owner-proving': + return status.direction === 'to-tui' ? 'Verifying agent terminal…' : 'Verifying chat session…' + case 'recovering': + return 'Recovering agent session…' + case 'manual-recovery': + return 'Agent session needs recovery' + default: + return 'Switching session owner…' + } +} diff --git a/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts b/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts index da03cdb5202..dffc0bb45d7 100644 --- a/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-coalescer.test.ts @@ -38,6 +38,42 @@ describe('mobile structured event coalescer', () => { vi.useRealTimers() }) + it('retains ownership projection while merging a reconnect batch', () => { + vi.useFakeTimers() + const seen: AgentSessionSubscribeEvent[] = [] + const coalescer = createMobileStructuredEventCoalescer((event) => seen.push(event)) + coalescer.push({ + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 1 }, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 3, + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' } + } + }) + coalescer.push(batch(2, 'continued')) + vi.advanceTimersByTime(48) + + expect(seen).toEqual([ + expect.objectContaining({ + type: 'batch', + fence: 3, + handoff: expect.objectContaining({ owner: 'tui', phase: 'idle' }) + }) + ]) + vi.useRealTimers() + }) + it('flushes pending text before a lifecycle row', () => { vi.useFakeTimers() const seen: AgentSessionSubscribeEvent[] = [] diff --git a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts index a647a5013fb..31a1a589470 100644 --- a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire' import { EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, reduceMobileStructuredAgentSession @@ -25,6 +26,90 @@ function snapshot(epoch: string, items: AgentJournalRenderItem[]) { } describe('mobile structured session reducer', () => { + it('projects every stage across transfer, recovery, and return', () => { + const handoffs: AgentSessionHandoffStatus[] = [ + { owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null }, + { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'forward' + }, + { + owner: 'none', + direction: 'to-tui', + phase: 'switching', + stage: 'old-owner-stopped', + operationId: 'forward' + }, + { + owner: 'none', + direction: 'to-tui', + phase: 'switching', + stage: 'new-owner-proving', + operationId: 'forward' + }, + { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-1', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' } + }, + { + owner: 'none', + direction: null, + phase: 'failed', + stage: 'manual-recovery', + operationId: null, + error: { message: 'Recovery required', recoverableOwner: 'none' } + }, + { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-2', tabId: 'tab-1', paneKey: 'tab-1:leaf-1' } + }, + { + owner: 'tui', + direction: 'to-native', + phase: 'switching', + stage: 'preparing', + operationId: 'reverse' + }, + { owner: 'native', direction: null, phase: 'idle', stage: null, operationId: null } + ] + let state = EMPTY_MOBILE_STRUCTURED_AGENT_SESSION + const projected: Array> = [] + + for (const [index, handoff] of handoffs.entries()) { + state = reduceMobileStructuredAgentSession(state, { + type: 'event', + event: { + ...snapshot('epoch-a', []), + fence: index + 1, + snapshot: { + ...snapshot('epoch-a', []).snapshot, + cursor: { epoch: 'epoch-a', sequence: index } + }, + handoff + } + }) + projected.push({ + owner: state.handoff!.owner, + phase: state.handoff!.phase, + stage: state.handoff!.stage + }) + } + + expect(projected).toEqual(handoffs.map(({ owner, phase, stage }) => ({ owner, phase, stage }))) + expect(state.handoff).toMatchObject({ owner: 'native', phase: 'idle', stage: null }) + }) + it('boots from a bounded tail page and retains its live resume cursor', () => { const state = reduceMobileStructuredAgentSession(EMPTY_MOBILE_STRUCTURED_AGENT_SESSION, { type: 'tail-page', diff --git a/mobile/src/session/mobile-structured-agent-session-view-styles.ts b/mobile/src/session/mobile-structured-agent-session-view-styles.ts index 4e4f0022e9f..6ef7fddd5b8 100644 --- a/mobile/src/session/mobile-structured-agent-session-view-styles.ts +++ b/mobile/src/session/mobile-structured-agent-session-view-styles.ts @@ -41,5 +41,30 @@ export const styles = StyleSheet.create({ paddingHorizontal: spacing.lg, paddingVertical: spacing.sm }, - writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize } + writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize }, + handoffBar: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.xs + }, + handoffBanner: { + minHeight: 42, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgRaised, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + handoffError: { backgroundColor: colors.bgRaised }, + handoffText: { flex: 1, color: colors.textSecondary, fontSize: typography.metaSize }, + handoffErrorText: { flex: 1, color: colors.statusRed, fontSize: typography.metaSize }, + handoffButton: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs }, + handoffButtonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' } }) diff --git a/mobile/src/session/mobile-structured-tui-send.test.ts b/mobile/src/session/mobile-structured-tui-send.test.ts new file mode 100644 index 00000000000..edfb665a282 --- /dev/null +++ b/mobile/src/session/mobile-structured-tui-send.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock' +import { + getMobileStructuredTuiTerminal, + sendMobileStructuredTuiMessage +} from './mobile-structured-tui-send' + +function acceptingClient(): RpcClient { + return { + sendRequest: vi.fn().mockResolvedValue({ + id: 'send', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + } as unknown as RpcClient +} + +afterEach(() => { + vi.useRealTimers() + resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() +}) + +describe('sendMobileStructuredTuiMessage', () => { + it('accepts only an idle TUI handoff terminal', () => { + expect( + getMobileStructuredTuiTerminal({ + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' } + }) + ).toBe('term-tui') + expect( + getMobileStructuredTuiTerminal({ + owner: 'tui', + direction: 'to-native', + phase: 'waiting-for-exit', + stage: 'wait-for-tui-exit', + operationId: 'handoff-1', + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' } + }) + ).toBeNull() + }) + + it('routes stable TUI composer text through terminal.send', async () => { + const client = acceptingClient() + + await expect( + sendMobileStructuredTuiMessage({ + client, + terminal: 'term-tui', + deviceToken: 'mobile-1', + text: 'hello', + attachments: [] + }) + ).resolves.toBe('accepted') + + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term-tui', + text: '\x15hello', + enter: true, + client: { id: 'mobile-1', type: 'mobile' } + }, + expect.objectContaining({ budgetSpansConnect: true }) + ) + }) + + it('types stable TUI slash commands one key at a time', async () => { + vi.useFakeTimers() + const client = acceptingClient() + + const sending = sendMobileStructuredTuiMessage({ + client, + terminal: 'term-tui', + deviceToken: null, + text: '/model', + attachments: [] + }) + await vi.runAllTimersAsync() + await expect(sending).resolves.toBe('accepted') + + expect( + vi + .mocked(client.sendRequest) + .mock.calls.map(([, params]) => (params as { text: string }).text) + ).toEqual(['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r']) + expect( + vi + .mocked(client.sendRequest) + .mock.calls.every( + ([method, params]) => + method === 'terminal.send' && (params as { enter: boolean }).enter === false + ) + ).toBe(true) + }) +}) diff --git a/mobile/src/session/mobile-structured-tui-send.ts b/mobile/src/session/mobile-structured-tui-send.ts new file mode 100644 index 00000000000..678c8222ccf --- /dev/null +++ b/mobile/src/session/mobile-structured-tui-send.ts @@ -0,0 +1,117 @@ +import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands' +import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire' +import type { RpcClient } from '../transport/rpc-client' +import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment' +import { + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, + pasteMobileNativeChatImagePaths +} from './mobile-native-chat-image-send' +import { + openMobileNativeChatSendBudget, + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, + type MobileNativeChatSendOutcome +} from './mobile-native-chat-send' +import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' +import { + clearMobileNativeChatInputStale, + healMobileNativeChatStaleInput, + markMobileNativeChatInputStale +} from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' + +type MobileStructuredTuiSendArgs = { + client: RpcClient + terminal: string + deviceToken: string | null + text: string + attachments: readonly PendingNativeChatImage[] + sleep?: (ms: number) => Promise +} + +const wait = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +export function getMobileStructuredTuiTerminal( + handoff: AgentSessionHandoffStatus | null +): string | null { + return handoff?.owner === 'tui' && handoff.phase === 'idle' + ? (handoff.terminal?.handle ?? null) + : null +} + +/** Sends the structured transcript composer through its proven TUI owner. */ +export async function sendMobileStructuredTuiMessage( + args: MobileStructuredTuiSendArgs +): Promise { + if (!acquireMobileNativeChatTerminalWrite(args.terminal)) { + return 'rejected' + } + try { + const deadline = openMobileNativeChatSendBudget() + const mobileClient = args.deviceToken + ? { id: args.deviceToken, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client: args.client, + terminal: args.terminal, + deviceToken: args.deviceToken, + deadline + })) + ) { + return 'rejected' + } + if (args.attachments.length > 0) { + const pasted = await pasteMobileNativeChatImagePaths({ + client: args.client, + terminal: args.terminal, + deviceToken: args.deviceToken, + imagePaths: args.attachments.map((attachment) => attachment.path), + deadline + }) + if (!pasted) { + markMobileNativeChatInputStale(args.terminal) + return 'rejected' + } + clearMobileNativeChatInputStale(args.terminal) + await (args.sleep ?? wait)(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS) + } + const textDeadline = + args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline + const classification = classifyMobileNativeChatSend('codex', args.text) + const outcome = + classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0 + ? await typeMobileNativeChatCommandWithOutcome({ + client: args.client, + terminal: args.terminal, + command: args.text, + ...(mobileClient ? { mobileClient } : {}), + deadline: textDeadline + }) + : await sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: args.text, + clearInputFirst: args.attachments.length === 0, + ...(mobileClient ? { mobileClient } : {}), + deadline: textDeadline + }) + if (args.attachments.length > 0 && outcome !== 'accepted') { + markMobileNativeChatInputStale(args.terminal) + } + return outcome + } catch { + if (args.attachments.length > 0) { + markMobileNativeChatInputStale(args.terminal) + } + return 'rejected' + } finally { + releaseMobileNativeChatTerminalWrite(args.terminal) + } +} diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index cf8fe08522c..aaa46cd4854 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'r import { AppState, type AppStateStatus } from 'react-native' import type { AgentSessionHistoryResult, + AgentSessionHandoffStatus, AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types' @@ -57,6 +58,7 @@ export function useMobileStructuredAgentSession(args: { hasOlder: boolean loadingOlder: boolean loadOlder: () => Promise + handoff: AgentSessionHandoffStatus | null } { const { client, sessionId } = args const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION) @@ -134,15 +136,23 @@ export function useMobileStructuredAgentSession(args: { } }) } - void client - .sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }) - .then((response) => { + void Promise.all([ + client.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }), + client.sendRequest('agentSession.handoffStatus', { sessionId }).catch(() => null) + ]) + .then(([response, handoffResponse]) => { if (closed) { return } if (!response.ok) { throw new Error(response.error.message) } + if (handoffResponse?.ok) { + dispatch({ + type: 'handoff', + handoff: handoffResponse.result as AgentSessionHandoffStatus + }) + } const result = response.result as AgentSessionHistoryResult if (result.ok) { dispatch({ type: 'tail-page', page: result.page }) @@ -240,6 +250,7 @@ export function useMobileStructuredAgentSession(args: { error: state.error || undefined, hasOlder: state.hasOlder, loadingOlder, - loadOlder + loadOlder, + handoff: state.handoff } } diff --git a/mobile/src/session/use-mobile-structured-session-entry.ts b/mobile/src/session/use-mobile-structured-session-entry.ts index dd1072e95d5..38af89b3ec6 100644 --- a/mobile/src/session/use-mobile-structured-session-entry.ts +++ b/mobile/src/session/use-mobile-structured-session-entry.ts @@ -52,7 +52,8 @@ export function useMobileStructuredSessionEntry(args: { connected, sessionId, fence: session.fence, - submissions: session.submissions + submissions: session.submissions, + handoffOperationId: session.handoff?.operationId }) const sessionOptions = useMobileStructuredSessionOptions({ client, diff --git a/mobile/src/session/use-mobile-structured-session-mutations.ts b/mobile/src/session/use-mobile-structured-session-mutations.ts index ed23de9d5c5..20fb2b72590 100644 --- a/mobile/src/session/use-mobile-structured-session-mutations.ts +++ b/mobile/src/session/use-mobile-structured-session-mutations.ts @@ -6,6 +6,8 @@ import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, AgentSessionMutationResult, AgentSessionPromptResult } from '../../../src/shared/agent-session-wire' @@ -25,12 +27,18 @@ export type MobileStructuredSessionMutations = { respondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise setOption: (key: string, value: string) => Promise cancel: (turnId: string) => Promise + requestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' + ) => Promise } export function useMobileStructuredSessionMutations(args: { client: RpcClient | null sessionId: string | null fence: number | null + handoffOperationId?: string | null onRefusal: (message: string | null) => void }): MobileStructuredSessionMutations { const operationIdsRef = useRef(new Map()) @@ -44,12 +52,18 @@ export function useMobileStructuredSessionMutations(args: { activeContextRef.current = { client, fence, sessionId } }, [client, fence, sessionId]) const mutate = useCallback( - async (method: string, fingerprintMethod: string, fields: Record) => { + async ( + method: string, + fingerprintMethod: string, + fields: Record, + operationIdOverride?: string | null + ) => { if (!client || !sessionId || fence === null || client.getState() !== 'connected') { return null } const mutationKey = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}` const clientOperationId = + operationIdOverride ?? operationIdsRef.current.get(mutationKey) ?? createStructuredAgentSessionOperationId(() => ExpoCrypto.randomUUID()) operationIdsRef.current.set(mutationKey, clientOperationId) @@ -117,9 +131,22 @@ export function useMobileStructuredSessionMutations(args: { setOption: async (key: string, value: string) => Boolean(await mutate('agentSession.setOption', 'agentSession.setOption', { key, value })), cancel: async (turnId: string) => - Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })) + Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })), + requestHandoff: async ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action: 'start' | 'cancel-queued' | 'retry' = 'start' + ) => + Boolean( + await mutate( + 'agentSession.requestHandoff', + 'agentSession.requestHandoff', + { direction, mode, action }, + action === 'retry' ? args.handoffOperationId : null + ) + ) }), - [mutate] + [args.handoffOperationId, mutate] ) } diff --git a/mobile/src/session/use-mobile-structured-session-writes.ts b/mobile/src/session/use-mobile-structured-session-writes.ts index bdc4b4afd5b..072495ff2aa 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.ts @@ -31,6 +31,7 @@ export function useMobileStructuredSessionWrites(args: { sessionId: string | null fence: number | null submissions: readonly AgentJournalSubmission[] + handoffOperationId?: string | null }): MobileStructuredSessionWrites { const { client, connected, sessionId, fence, submissions } = args const [outbox, setOutbox] = useState([]) @@ -48,6 +49,7 @@ export function useMobileStructuredSessionWrites(args: { client, sessionId, fence, + handoffOperationId: args.handoffOperationId, onRefusal: setError }) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index c1870d35a7c..3fecfad10a7 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -97,6 +97,41 @@ afterEach(() => { vi.restoreAllMocks() }) +describe('AgentHookServer raw Claude proof envelopes', () => { + it('publishes the authenticated SessionStart body before status normalization', async () => { + const server = new AgentHookServer() + const received: unknown[] = [] + const unsubscribe = server.subscribeClaudeHookEnvelopes((envelope) => received.push(envelope)) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const body = buildBody( + { + hook_event_name: 'SessionStart', + source: 'resume', + session_id: 'claude-session', + transcript_path: '/tmp/claude-session.jsonl' + }, + { launchToken: 'claude-spawn-token' } + ) + const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN + }, + body: JSON.stringify(body) + }) + + expect(response.status).toBe(204) + expect(received).toEqual([body]) + } finally { + unsubscribe() + server.stop() + } + }) +}) + describe('AgentHookServer listener replay', () => { it('preserves Codex sibling and lead state across relay listener restarts', () => { const server = new AgentHookServer() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index e5ef70e1395..58323183659 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -604,6 +604,7 @@ export class AgentHookServer { private paneStatusClearListeners = new Set() private statusChangeListeners = new Set() private providerSessionChangeListeners = new Set() + private claudeHookEnvelopeListeners = new Set<(envelope: unknown) => void>() // Why: setListener is a single slot owned by the main-window fanout; the // plugin event bus (and future consumers) need an additive subscription // that also works in headless serve, where no window listener exists. @@ -674,6 +675,23 @@ export class AgentHookServer { } } + subscribeClaudeHookEnvelopes(listener: (envelope: unknown) => void): () => void { + this.claudeHookEnvelopeListeners.add(listener) + return () => { + this.claudeHookEnvelopeListeners.delete(listener) + } + } + + private emitClaudeHookEnvelope(envelope: unknown): void { + for (const listener of this.claudeHookEnvelopeListeners) { + try { + listener(envelope) + } catch (err) { + console.error('[agent-hooks] Claude hook envelope listener threw', err) + } + } + } + /** Multi-subscriber tap on every enriched status change (no replay). */ subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { this.enrichedStatusListeners.add(listener) @@ -2145,6 +2163,9 @@ export class AgentHookServer { trackEmptyPaneKeyHook(body) const aliasedBody = this.normalizeHookBodyPaneKeyAlias(body) + if (source === 'claude') { + this.emitClaudeHookEnvelope(aliasedBody) + } const normalized = this.normalizeLocalHookPayload(source, aliasedBody) const statusDisposition = normalized.event ? this.getAgentStatusDisposition(normalized.event.paneKey, { diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts index ea9e956347c..d98f7f32505 100644 --- a/src/main/ai-vault/structured-session-ownership.test.ts +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it } from 'vitest' -import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { assertLegacyAiVaultResumeAllowed, diff --git a/src/main/codex/codex-tui-resume-real-binary.integration.test.ts b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts new file mode 100644 index 00000000000..cada4249591 --- /dev/null +++ b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts @@ -0,0 +1,160 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' +import { resolveCodexCommand } from '../codex-cli/command' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { openCodexThread } from './codex-structured-thread-open' +import { proveCodexTuiRollout } from './codex-tui-rollout-proof' + +const codexCommand = resolveCodexCommand() +const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0 +const itWithCodex = codexAvailable ? it : it.skip +const tempHomes: string[] = [] + +async function waitForTuiStart(proc: pty.IPty): Promise { + let output = '' + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)), + 15_000 + ) + proc.onData((data) => { + output += data + if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) { + clearTimeout(timeout) + resolve(output) + } + }) + proc.onExit(({ exitCode }) => { + clearTimeout(timeout) + reject( + new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`) + ) + }) + }) +} + +async function waitForRollout(path: string, threadId: string): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + const rollout = await readFile(path, 'utf8') + if (rollout.includes(threadId)) { + return rollout + } + } catch { + // The rollout is created asynchronously after thread/start. + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error('Codex did not materialize the resumed rollout') +} + +afterEach(async () => { + await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) +}) + +describe('real Codex structured-to-TUI resume', () => { + itWithCodex( + 'resumes the exact isolated rollout and reaches the initial TUI screen', + async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-')) + tempHomes.push(codexHome) + await writeFile( + join(codexHome, 'config.toml'), + [ + 'model_provider = "orca-integration"', + 'model = "gpt-5"', + '', + '[model_providers.orca-integration]', + 'name = "Orca integration"', + 'base_url = "http://127.0.0.1:9/v1"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + `[projects.${JSON.stringify(process.cwd())}]`, + 'trust_level = "trusted"', + '' + ].join('\n') + ) + const connection = await openCodexAppServerConnection({ + command: codexCommand, + args: ['app-server'], + env: { CODEX_HOME: codexHome } + }) + const opened = await openCodexThread( + connection, + { cwd: process.cwd(), resumeThreadId: null }, + 15_000 + ) + await connection.request( + 'turn/start', + { + threadId: opened.threadId, + clientUserMessageId: 'real-binary-resume-fixture', + input: [{ type: 'text', text: 'materialize the isolated resume fixture' }] + }, + { timeoutMs: 15_000 } + ) + expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId) + await connection.close() + + expect(opened.historyPath).toContain(opened.threadId) + expect(opened.historyPath).toContain(join(codexHome, 'sessions')) + const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { + ...process.env, + CODEX_HOME: codexHome, + ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof', + TERM: 'xterm-256color' + } + }) + const tuiExit = new Promise((resolve) => + tui.onExit(({ exitCode }) => resolve(exitCode)) + ) + const kittyKeyboard = new TerminalKittyKeyboardModeTracker() + let tuiOutput = '' + let lastOutputAt: number | null = null + tui.onData((data) => { + tuiOutput += data + lastOutputAt = Date.now() + kittyKeyboard.scan(data) + }) + try { + await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i) + const proof = await proveCodexTuiRollout({ + codexHome, + threadId: opened.threadId, + kittyKeyboardFlags: kittyKeyboard.flags, + readOutput: () => ({ text: tuiOutput, lastOutputAt }), + write: (data) => { + tui.write(data) + return true + } + }) + expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!)) + } finally { + try { + tui.kill() + } catch { + // Already exited. + } + await Promise.race([ + tuiExit, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000) + ) + ]) + } + }, + 30_000 + ) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts new file mode 100644 index 00000000000..0ec6943d4db --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { + codexTuiStatusProbeInput, + parseCodexTuiStatusSessionId, + proveCodexTuiRollout, + resolvePinnedCodexRolloutProof +} from './codex-tui-rollout-proof' + +const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70' +const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71' + +describe('Codex TUI rollout proof', () => { + it('parses the exact session shown by status', () => { + expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull() + }) + + it('uses Kitty Enter only while the TUI negotiated Kitty input', () => { + expect(codexTuiStatusProbeInput(0)).toEqual({ + command: '\u001b[200~/status\u001b[201~', + submit: '\r' + }) + expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u') + expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u') + }) + + it('resolves only the exact session_meta rollout under the pinned account home', async () => { + const files = async function* (): AsyncGenerator { + yield '/pinned/sessions/scratch/rollout-wrong.jsonl' + yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + const readSessionMetaId = vi.fn(async () => THREAD) + + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId }) + ).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`) + expect(readSessionMetaId).toHaveBeenCalledTimes(1) + }) + + it('rejects a rollout whose session_meta names another thread', async () => { + const files = async function* (): AsyncGenerator { + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { + listFiles: files, + readSessionMetaId: async () => OTHER_THREAD + }) + ).resolves.toBeNull() + }) + + it('rejects a different status session after filesystem proof', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: `Session: ${OTHER_THREAD}`, + lastOutputAt: reads++ > 0 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).rejects.toThrow('resumed a different Codex session') + expect(write).toHaveBeenCalledTimes(2) + }) + + it('dismisses status only after the exact session is observed', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts new file mode 100644 index 00000000000..190d33a2ebe --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -0,0 +1,144 @@ +import { open } from 'node:fs/promises' +import { join } from 'node:path' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { relativePathInsideRoot } from '../../shared/cross-platform-path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + +const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const STATUS_SESSION_RE = new RegExp(`\\bSession(?: ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, 'gi') +const ROLLOUT_READ_LIMIT = 64 * 1024 +const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' +const KITTY_ENTER = '\u001b[13u' + +export type CodexTuiProofOutput = { + text: string + lastOutputAt: number | null +} + +export type CodexTuiRolloutProofOptions = { + listFiles?: (sessionsRoot: string) => AsyncIterable + readSessionMetaId?: (filePath: string) => Promise +} + +export function parseCodexTuiStatusSessionId(output: string): string | null { + let sessionId: string | null = null + for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) { + sessionId = match[1] ?? null + } + return sessionId +} + +export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string { + return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r' +} + +export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): { + command: string + submit: string +} { + return { + command: STATUS_COMMAND_PASTE, + submit: codexTuiStatusSubmitInput(kittyKeyboardFlags) + } +} + +export async function resolvePinnedCodexRolloutProof( + codexHome: string, + threadId: string, + options: CodexTuiRolloutProofOptions = {} +): Promise { + const sessionsRoot = join(codexHome, 'sessions') + const listFiles = + options.listFiles ?? + ((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 })) + const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId + const expectedSuffix = `-${threadId}.jsonl`.toLowerCase() + + for await (const filePath of listFiles(sessionsRoot)) { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/') + if ( + !relativePath || + !/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) || + !relativePath.toLowerCase().endsWith(expectedSuffix) + ) { + continue + } + if ((await readSessionMetaId(filePath)) === threadId) { + return filePath + } + } + return null +} + +export async function proveCodexTuiRollout(input: { + codexHome: string + threadId: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ transcriptPath: string }> { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + const transcriptPath = await resolveRollout(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const output = input.readOutput() + if (output.lastOutputAt !== baselineOutputAt) { + const observedThreadId = parseCodexTuiStatusSessionId(output.text) + if (observedThreadId && observedThreadId !== input.threadId) { + throw new Error('The agent terminal resumed a different Codex session.') + } + if (observedThreadId === input.threadId) { + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + return { transcriptPath } + } + } + await delay(100) + } + throw new Error('The agent terminal did not prove the expected Codex rollout.') +} + +async function readCodexRolloutSessionMetaId(filePath: string): Promise { + const file = await open(filePath, 'r') + try { + const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim() + if (!firstLine) { + return null + } + const record = JSON.parse(firstLine) as { + type?: unknown + id?: unknown + payload?: { id?: unknown } + } + if (record.type !== 'session_meta') { + return null + } + const id = record.payload?.id ?? record.id + return typeof id === 'string' && id.length > 0 ? id : null + } catch { + return null + } finally { + await file.close() + } +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 08ebb1b2183..a700ec67bf0 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5338,6 +5338,7 @@ export function registerPtyHandlers( } const response = { id: result.id, + ...(result.pid ? { pid: result.pid } : {}), ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), ...(stablePaneOwner && (stablePaneOwner.handle || args.preAllocatedHandle) ? { @@ -5389,6 +5390,17 @@ export function registerPtyHandlers( return false } }, + writeAgentSessionProof: (ptyId, data, authority) => { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + return false + } + try { + getProviderForPty(ptyId).write(ptyId, data) + return true + } catch { + return false + } + }, probePtyLiveness: async (ptyId) => { try { // Why: no locally routed provider can authoritatively answer for a diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index fcf52816dab..4bbfdb06226 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -85,6 +85,40 @@ describe('registerRuntimeHandlers', () => { }) }) + it('projects Claude structured tabs to the same-version desktop client', async () => { + const claudeTab = { + type: 'agent-session', + id: 'agent-session:claude-1', + title: 'Claude Chat', + sessionId: 'claude-1', + agent: 'claude', + isActive: true + } + const runtime = { + getRuntimeId: vi.fn().mockReturnValue('runtime-1'), + restoreStructuredAgentSessionTabs: vi.fn(async () => undefined), + listMobileSessionTabs: vi.fn(async () => ({ + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: claudeTab.id, + activeTabType: 'agent-session', + tabGroups: [{ id: 'group-1', activeTabId: claudeTab.id, tabOrder: [claudeTab.id] }], + tabs: [claudeTab] + })) + } + + registerRuntimeHandlers(runtime as never) + const callRegistration = handleMock.mock.calls.find(([channel]) => channel === 'runtime:call') + const result = await callRegistration![1]( + { sender: {} }, + { method: 'session.tabs.list', params: { worktree: 'id:workspace-1' } } + ) + + expect(result).toMatchObject({ ok: true, result: { tabs: [claudeTab] } }) + }) + it('registers project group runtime RPC methods for local desktop callers', async () => { const runtime = { syncWindowGraph: vi.fn(), diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index dc1ff4ff4e4..4b91ec7f299 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -9,10 +9,18 @@ import type { } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import { + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../shared/protocol-version' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import { ALL_RPC_METHODS } from '../runtime/rpc/methods' +const LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES = [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +] as const + function boundTerminalFitRestore(pending: Promise): Promise { let timer: ReturnType | undefined const deadline = new Promise((resolve) => { @@ -62,7 +70,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { { clientId: 'desktop-renderer', clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES } )) as RuntimeRpcResponse } @@ -104,7 +112,7 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { signal: controller.signal, clientId: 'desktop-renderer', clientKind: 'runtime', - clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + clientCapabilities: LOCAL_DESKTOP_STRUCTURED_SESSION_CAPABILITIES } ) .finally(stop) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 1cc3809847f..d931327c14d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -62,6 +62,15 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) + async closeSession(sessionId: string): Promise { + const adapter = this.owners.get(sessionId) + if (!adapter) { + return + } + await adapter.closeSession?.(sessionId) + this.owners.delete(sessionId) + } + async closeAll(): Promise { this.owners.clear() await this.closeAdapters() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index b449149fb06..54d24fd54a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -105,4 +105,6 @@ export type StructuredAgentSessionAdapter = { /** Transcript path for journal recovery. Omit to let the existing session-file * resolver discover it from the provider session id. */ historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise + /** Gracefully stops the structured owner after its event stream is drained. */ + closeSession?(sessionId: string): Promise } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts new file mode 100644 index 00000000000..d43470a67a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-admission.ts @@ -0,0 +1,126 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { AGENT_SESSION_WIRE_REFUSAL_CODES } from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export type StructuredHandoffAdmission = + | { decision: 'continue'; record: AgentSessionRecord; fingerprint: string } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +export async function admitStructuredHandoffRequest(input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + callerKey: string + params: AgentSessionHandoffRequest + record: AgentSessionRecord + status?: AgentSessionHandoffStatus +}): Promise { + const action = input.params.action ?? 'start' + const requestFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction, mode: input.params.mode, action } + }) + const conflict = agentSessionFingerprintConflict(input.params.envelope, requestFingerprint) + if (conflict) { + return { decision: 'refused', refusal: conflict } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff.operation', + sessionId: input.record.sessionId, + fields: { direction: input.params.direction } + }) + const operation = await input.operationGuard.check({ + callerKey: input.callerKey, + sessionId: input.record.sessionId, + operationId: input.params.envelope.clientOperationId, + fingerprint, + action, + ...(input.status ? { status: input.status } : {}), + now: input.deps.now() + }) + if (operation.decision === 'replay') { + return { decision: 'replay', outcome: operation.outcome } + } + if (operation.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: operation.code as 'agent_session_operation_conflict', + message: 'This handoff operation could not be admitted.' + } + } + } + if (input.params.envelope.expectedRuntimeFence !== input.record.lease.runtimeFence) { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_checkpoint_stale' } + }) + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'The session owner changed before the handoff request arrived.', + currentFence: input.record.lease.runtimeFence + } + } + } + return { decision: 'continue', record: input.record, fingerprint } +} + +export function replayedStructuredHandoffRefusal( + outcome: AgentSessionOperationOutcome +): AgentSessionWireRefusal | null { + if ( + outcome.status !== 'failed' || + !AGENT_SESSION_WIRE_REFUSAL_CODES.includes( + outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + ) + ) { + return null + } + return { + code: outcome.code as (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number], + message: 'This handoff request was previously refused.' + } +} + +export async function refuseAdmittedStructuredHandoff(input: { + deps: StructuredAgentSessionHandoffDeps + callerKey: string + params: AgentSessionHandoffRequest + refusal: AgentSessionWireRefusal +}): Promise> { + await input.deps.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { status: 'failed', code: input.refusal.code } + }) + return { ok: false, refusal: input.refusal } +} + +export function structuredHandoffRetryIsAdmissible( + status: AgentSessionHandoffStatus, + params: AgentSessionHandoffRequest +): boolean { + return ( + status.phase === 'failed' && + status.direction === params.direction && + status.operationId === params.envelope.clientOperationId && + status.error?.recoverableOwner !== 'none' + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts new file mode 100644 index 00000000000..2aa2eb440ae --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts @@ -0,0 +1,33 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function createStructuredHandoffFlowContext(input: { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + enterPreparing: StructuredAgentSessionHandoffFlowContext['enterPreparing'] + publishStage: StructuredAgentSessionHandoffFlowContext['publishStage'] + requireRecord: (sessionId: string) => AgentSessionRecord +}): StructuredAgentSessionHandoffFlowContext { + return input +} + +export async function stopStructuredNativeTurn( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + turnId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record || record.lease.runtimeKind !== 'native') { + return false + } + const session = deps.session(sessionId) + return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts new file mode 100644 index 00000000000..bfd1b1f0e05 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-runner.ts @@ -0,0 +1,85 @@ +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { stopStructuredNativeTurn } from './structured-agent-session-handoff-flow-context' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import type { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffFlowRunner { + private readonly active = new Set>() + + constructor( + private readonly input: { + deps: StructuredAgentSessionHandoffDeps + operationGuard: StructuredAgentSessionHandoffOperationGuard + flowContext: () => StructuredAgentSessionHandoffFlowContext + fail: (params: AgentSessionHandoffRequest, error: unknown) => void + } + ) {} + + async drain(): Promise { + await Promise.allSettled(this.active) + } + + begin(input: { + callerKey: string + params: AgentSessionHandoffRequest + turnId: string | null + fingerprint: string + tuiAlreadyExited?: boolean + }): void { + const { callerKey, params, turnId, fingerprint, tuiAlreadyExited = false } = input + const sessionId = params.envelope.sessionId + this.input.operationGuard.start(sessionId, { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint + }) + const flow = this.run(params, turnId, tuiAlreadyExited) + .then(() => + this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + ) + .catch(async (error) => { + await this.input.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + this.input.fail(params, error) + }) + .finally(() => this.input.operationGuard.finish(sessionId, params.envelope.clientOperationId)) + this.active.add(flow) + void flow.finally(() => this.active.delete(flow)) + } + + private run( + params: AgentSessionHandoffRequest, + turnId: string | null, + tuiAlreadyExited: boolean + ): Promise { + const sessionId = params.envelope.sessionId + return this.input.deps.schedule(sessionId, async () => { + if (turnId && params.mode === 'stop-turn') { + const stopped = await stopStructuredNativeTurn(this.input.deps, sessionId, turnId) + if (!stopped) { + throw new Error('The current turn did not acknowledge cancellation.') + } + } + await (params.direction === 'to-tui' + ? handoffStructuredSessionToTui(this.input.flowContext(), params, params.action === 'retry') + : handoffStructuredSessionToNative( + this.input.flowContext(), + params, + params.action === 'retry', + tuiAlreadyExited + )) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts new file mode 100644 index 00000000000..ede43215076 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + reserveStoredAgentSessionHandoffOwner, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import type { + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToTui( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + if (retry && record.lease.handoffStage === 'old-owner-stopped') { + record = await recoverNativeAfterTuiFailure(context, sessionId, operationId) + } + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-tui') + } else if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + record = context.requireRecord(sessionId) + await deps.suspendNative(sessionId) + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + let owner: StructuredTuiOwner | null = null + try { + owner = await deps.transport!.launchTui({ + record, + fence: record.lease.runtimeFence, + spawnToken + }) + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: owner.process, + now: deps.now() + }) + record = await deps.store.proveOwner({ + sessionId, + fence: record.lease.runtimeFence, + link: owner.link, + now: deps.now() + }) + } catch (error) { + if (owner) { + if (!deps.transport?.stopFailedTuiLaunch) { + await markManualRecovery(context, sessionId, operationId) + throw error + } + try { + await deps.transport.stopFailedTuiLaunch(owner) + } catch (stopError) { + await markManualRecovery(context, sessionId, operationId) + throw new AggregateError( + [error, stopError], + 'The failed terminal launch could not be proven stopped.' + ) + } + } + await recoverNativeAfterTuiFailure(context, sessionId, operationId) + throw error + } + context.retainOwner(sessionId, owner) + context.setStatus(sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) +} + +async function markManualRecovery( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +): Promise { + const { deps } = context + const record = context.requireRecord(sessionId) + await setStoredAgentSessionHandoffStage(deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: operationId, + now: deps.now() + }) +} + +async function recoverNativeAfterTuiFailure( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +) { + const { deps } = context + let record = context.requireRecord(sessionId) + if (record.lease.handoffStage === 'new-owner-proving') { + record = await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + try { + return await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts new file mode 100644 index 00000000000..00896472a00 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -0,0 +1,73 @@ +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string } + +export type HandoffOperationDecision = + | { decision: 'new' } + | { decision: 'replay'; outcome: AgentSessionOperationOutcome } + | { decision: 'retry' } + | { decision: 'refused'; code: 'agent_session_operation_conflict' | string } + +export class StructuredAgentSessionHandoffOperationGuard { + private readonly activeBySession = new Map() + + constructor(private readonly store: AgentSessionRecordStore) {} + + async check(input: { + callerKey: string + sessionId: string + operationId: string + fingerprint: string + action: 'start' | 'cancel-queued' | 'retry' + status?: AgentSessionHandoffStatus + now: number + }): Promise { + const active = this.activeBySession.get(input.sessionId) + if ( + active?.operationId === input.operationId && + (active.fingerprint !== input.fingerprint || active.callerKey !== input.callerKey) + ) { + return { decision: 'refused', code: 'agent_session_operation_conflict' } + } + const ledger = await this.store.admitOperation({ + callerKey: input.callerKey, + operationId: input.operationId, + fingerprint: input.fingerprint, + now: input.now + }) + if (ledger.decision === 'refused') { + return { decision: 'refused', code: ledger.code } + } + if (ledger.decision === 'admit') { + return { decision: 'new' } + } + if (input.action === 'retry' && ledger.row.outcome.status === 'failed') { + await this.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.operationId, + outcome: { status: 'pending' } + }) + return { decision: 'retry' } + } + if ( + ledger.row.outcome.status === 'pending' && + !active && + input.status?.operationId !== input.operationId + ) { + return { decision: 'new' } + } + return { decision: 'replay', outcome: ledger.row.outcome } + } + + start(sessionId: string, operation: ActiveOperation): void { + this.activeBySession.set(sessionId, operation) + } + + finish(sessionId: string, operationId: string): void { + if (this.activeBySession.get(sessionId)?.operationId === operationId) { + this.activeBySession.delete(sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts new file mode 100644 index 00000000000..bee387a9a68 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-queue.ts @@ -0,0 +1,44 @@ +export class StructuredAgentSessionHandoffQueue { + private readonly controllers = new Map() + + cancel(sessionId: string): void { + this.controllers.get(sessionId)?.abort() + this.controllers.delete(sessionId) + } + + enqueue( + sessionId: string, + isIdle: (signal: AbortSignal) => boolean | Promise, + onReady: () => void + ): void { + this.cancel(sessionId) + const controller = new AbortController() + this.controllers.set(sessionId, controller) + void this.waitUntilIdle(sessionId, controller, isIdle).then((ready) => { + if (ready) { + onReady() + } + }) + } + + private async waitUntilIdle( + sessionId: string, + controller: AbortController, + isIdle: (signal: AbortSignal) => boolean | Promise + ): Promise { + while (this.controllers.get(sessionId) === controller && !controller.signal.aborted) { + try { + if (await isIdle(controller.signal)) { + this.controllers.delete(sessionId) + return true + } + } catch { + if (controller.signal.aborted) { + return false + } + } + await new Promise((resolve) => setTimeout(resolve, 150)) + } + return false + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts new file mode 100644 index 00000000000..ab21fe630ab --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -0,0 +1,281 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { + abandonStoredAgentSessionHandoffAttempt, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff, + stopStoredRecoveringTuiOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +type RestartAccess = { + deps: StructuredAgentSessionHandoffDeps + requireRecord: (sessionId: string) => AgentSessionRecord + flowContext: () => StructuredAgentSessionHandoffFlowContext + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +} + +export async function restoreStructuredAgentSessionHandoff( + input: RestartAccess, + sessionId: string +): Promise { + const initial = input.requireRecord(sessionId) + const operationId = initial.lease.handoffOperationId + const initialStage = initial.lease.handoffStage + if ( + (initialStage === 'recovering' || initialStage === 'manual-recovery') && + !canRestoreLiveTuiOwner(initial) + ) { + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_ownership_unknown' } + }) + } + input.setStatus(sessionId, idleStructuredHandoffStatus(initial)) + return + } + let lastError: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await restoreOnce(input, input.requireRecord(sessionId)) + const settled = input.requireRecord(sessionId) + if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) { + throw new Error('Restart handoff reconciliation did not settle the transfer.') + } + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'succeeded', sessionId } + }) + } + return + } catch (error) { + lastError = error + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } + } + const current = input.requireRecord(sessionId) + const failed = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId, + fence: current.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: current.lease.handoffOperationId, + now: input.deps.now() + }) + const status = idleStructuredHandoffStatus(failed) + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } + input.setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: lastError instanceof Error ? lastError.message : String(lastError) + } + } + : {}) + }) +} + +async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise { + if (canRestoreLiveTuiOwner(record)) { + await restoreRecoverableLiveTui(input, record) + return + } + if (!input.deps.transport) { + if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + return + } + if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') { + await restoreLiveTui(input, record) + return + } + if (!record.lease.handoffOperationId) { + return + } + if (record.lease.handoffStage === 'preparing') { + await restorePreparing(input, record) + return + } + if (record.lease.handoffStage === 'new-owner-proving') { + await restoreProving(input, record) + return + } + if (record.lease.handoffStage === 'old-owner-stopped') { + await continueHandoff(input, record) + } +} + +async function recoverUnavailableTuiAsNative( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + await input.deps.transport!.stopRecoveredOwner(record) + const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now()) + const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean { + return ( + (record.lease.handoffStage === 'recovering' || + record.lease.handoffStage === 'manual-recovery') && + record.lease.runtimeKind === 'tui' && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null && + record.lease.handoffOperationId === null + ) +} + +async function restoreRecoverableLiveTui( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + if (!input.deps.transport) { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + let owner: StructuredTuiOwner + try { + const recovered = await input.deps.transport.recoverTuiOwner(record) + owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) + } catch { + await recoverUnavailableTuiAsNative(input, record) + return + } + await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + input.retainOwner(record.sessionId, owner) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport.hostLabel + }) +} + +async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise { + const owner = await input.deps.transport!.recoverTuiOwner(record) + input.retainOwner(record.sessionId, owner) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport?.hostLabel + }) +} + +async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise { + if (record.lease.runtimeKind === 'tui') { + const owner = await input.deps.transport!.recoverTuiOwner(record) + await input.deps.transport!.reproveTuiOwner({ record, owner }) + const settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + await restoreLiveTui(input, settled) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise { + const operationId = record.lease.handoffOperationId! + if (record.lease.runtimeKind === 'tui') { + const owner = await input.deps.transport!.recoverTuiOwner(record) + const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner }) + await input.deps.store.proveOwner({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + link: reproved.link, + now: input.deps.now() + }) + input.retainOwner(record.sessionId, reproved) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: reproved.terminal, + hostLabel: input.deps.transport?.hostLabel + }) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { + const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' + const operationId = record.lease.handoffOperationId! + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: record.sessionId, + clientOperationId: operationId, + expectedRuntimeFence: record.lease.runtimeFence, + payloadFingerprint: 'restart-reconciliation' + }, + direction, + mode: 'now', + action: 'retry' + } + await (direction === 'to-tui' + ? handoffStructuredSessionToTui(input.flowContext(), params, true) + : handoffStructuredSessionToNative(input.flowContext(), params, true)) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts new file mode 100644 index 00000000000..fe480d6359c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-result.ts @@ -0,0 +1,32 @@ +import type { + AgentSessionHandoffResult, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types' + +export function structuredHandoffRefusal( + code: AgentSessionWireRefusal['code'], + message: string +): AgentSessionWireRefusal { + return { code, message } +} + +export function structuredHandoffSuccess( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + replayed: boolean, + status: AgentSessionHandoffResult['status'] +): AgentSessionMutationResult { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return { + ok: true, + replayed, + fence: record.lease.runtimeFence, + cursor: deps.session(sessionId).journal.cursor(), + value: { status } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts new file mode 100644 index 00000000000..8c6904d1a10 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -0,0 +1,128 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + reserveStoredAgentSessionHandoffOwner, + rollbackStoredAgentSessionHandoffPreparation, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToNative( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean, + tuiAlreadyExited = false +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + let owner = context.owner(sessionId) + let transcriptPath = owner?.transcriptPath + if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) { + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-native') + } + record = context.requireRecord(sessionId) + try { + if (!owner) { + throw new Error('The owning agent terminal could not be identified.') + } + if (!tuiAlreadyExited) { + owner = await deps.transport!.reproveTuiOwner({ record, owner }) + context.retainOwner(sessionId, owner) + } + transcriptPath = owner.transcriptPath ?? transcriptPath + context.setStatus(sessionId, { + owner: 'tui', + direction: 'to-native', + phase: 'waiting-for-exit', + stage: 'preparing', + operationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) + const exited = await deps.transport!.waitForTuiExit(owner, async (link) => { + await deps.store.recordProviderHandle({ + sessionId, + fence: record.lease.runtimeFence, + link, + now: deps.now() + }) + }) + transcriptPath = exited.transcriptPath ?? owner.transcriptPath + } catch (error) { + const current = context.requireRecord(sessionId) + if ( + current.lease.handoffStage === 'preparing' && + current.lease.handoffOperationId === operationId && + current.lease.claimStatus === 'live' && + current.lease.ownerProcess + ) { + await rollbackStoredAgentSessionHandoffPreparation(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + now: deps.now() + }) + } + throw error + } + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + } else if ( + record.lease.handoffStage !== 'old-owner-stopped' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + await deps.importTuiHistory({ + sessionId, + fence: record.lease.runtimeFence, + ...(transcriptPath ? { transcriptPath } : {}) + }) + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + try { + record = await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: deps.now() + }) + } + throw error + } + context.releaseOwner(sessionId) + context.setStatus(sessionId, { + owner: 'native', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts new file mode 100644 index 00000000000..f159a9a51c4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -0,0 +1,149 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function structuredTuiStatus( + owner: StructuredTuiOwner | undefined, + transport: StructuredAgentSessionHandoffTransport | undefined +): 'idle' | 'busy' { + return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy' +} + +export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus { + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.handoffOperationId + ) { + return persistedFailedStructuredHandoffStatus(record) + } + if (record.lease.handoffStage === 'manual-recovery') { + return { + owner: 'none', + direction: null, + phase: 'failed', + stage: 'manual-recovery', + operationId: record.lease.handoffOperationId, + error: { + message: "Couldn't verify which runtime owns this session — manual recovery is required", + recoverableOwner: 'none' + } + } + } + if (record.lease.handoffStage) { + const direction = + record.lease.handoffStage === 'preparing' + ? record.lease.runtimeKind === 'native' + ? 'to-tui' + : 'to-native' + : record.lease.runtimeKind === 'tui' + ? 'to-tui' + : 'to-native' + return { + owner: + record.lease.claimStatus === 'live' && record.lease.ownerProcess + ? record.lease.runtimeKind + : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } + } + return { + owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } +} + +function persistedFailedStructuredHandoffStatus( + record: AgentSessionRecord +): AgentSessionHandoffStatus { + const recoverableOwner = record.lease.runtimeKind + const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native' + return { + owner: recoverableOwner, + direction, + phase: 'failed', + stage: 'old-owner-stopped', + operationId: record.lease.handoffOperationId, + error: { + message: + direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + recoverableOwner + } + } +} + +export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean { + return journal + .snapshot() + .items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export function switchingStructuredHandoffStatus( + record: AgentSessionRecord, + direction: 'to-tui' | 'to-native', + hostLabel?: string +): AgentSessionHandoffStatus { + return { + owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + ...(hostLabel ? { hostLabel } : {}) + } +} + +export function failedStructuredHandoffStatus( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest, + error: unknown, + hostLabel?: string +): AgentSessionHandoffStatus { + const recoverableOwner = + record.lease.handoffStage === 'manual-recovery' + ? 'none' + : record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released' + ? record.lease.runtimeKind + : record.lease.ownerProcess + ? record.lease.runtimeKind + : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' + ? 'native' + : 'none' + return { + owner: recoverableOwner, + direction: params.direction, + phase: 'failed', + stage: record.lease.handoffStage, + operationId: params.envelope.clientOperationId, + ...(hostLabel ? { hostLabel } : {}), + error: { + message: + recoverableOwner === 'none' + ? "Couldn't verify which runtime owns this session — manual recovery is required" + : params.direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + details: error instanceof Error ? error.message : String(error), + recoverableOwner + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts new file mode 100644 index 00000000000..e9c2d2c489b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts @@ -0,0 +1,78 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +type TestCoordinatorInput = { + store: AgentSessionRecordStore + journal: AgentSessionJournal + sessionId: string + provider: 'claude' | 'codex' + claudeSessionId: string + codexThreadId: string + now: number + launchTui: StructuredAgentSessionHandoffTransport['launchTui'] + reproveTuiOwner: StructuredAgentSessionHandoffTransport['reproveTuiOwner'] + stopRecoveredOwner: StructuredAgentSessionHandoffTransport['stopRecoveredOwner'] + waitForTuiExit: StructuredAgentSessionHandoffTransport['waitForTuiExit'] + waitForTuiIdleOrExit: StructuredAgentSessionHandoffTransport['waitForTuiIdleOrExit'] + stopFailedTuiLaunch: NonNullable + recoverTuiOwner: (record: AgentSessionRecord) => Promise + tuiStatus: () => 'idle' | 'busy' + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop: (turnId: string) => Promise + takeImportFailure: () => Error | null + statuses: AgentSessionHandoffStatus[] +} + +export function createStructuredAgentSessionHandoffTestCoordinator( + input: TestCoordinatorInput +): StructuredAgentSessionHandoffCoordinator { + return new StructuredAgentSessionHandoffCoordinator({ + store: input.store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: input.launchTui, + reproveTuiOwner: input.reproveTuiOwner, + recoverTuiOwner: input.recoverTuiOwner, + stopRecoveredOwner: input.stopRecoveredOwner, + waitForTuiExit: input.waitForTuiExit, + waitForTuiIdleOrExit: input.waitForTuiIdleOrExit, + tuiStatus: input.tuiStatus, + stopFailedTuiLaunch: input.stopFailedTuiLaunch + }, + session: () => ({ + journal: input.journal, + fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1 + }), + suspendNative: async () => undefined, + acquireNative: input.acquireNative, + acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId), + importTuiHistory: async ({ fence }) => { + const importFailure = input.takeImportFailure() + if (importFailure) { + throw importFailure + } + await input.journal.appendItem( + input.provider === 'claude' + ? { provider: 'claude', sessionId: input.claudeSessionId, uuid: 'tui-turn' } + : { provider: 'codex', threadId: input.codexThreadId, turnId: 'tui-turn', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, + { fence, recovered: true } + ) + }, + publish: (_sessionId, status) => input.statuses.push(status), + schedule: async (_sessionId, task) => task(), + now: () => input.now + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts new file mode 100644 index 00000000000..ca33e486999 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-identities.ts @@ -0,0 +1,40 @@ +export type StructuredHandoffProviderCase = { + provider: 'claude' | 'codex' + accountHome: { variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME'; pathName: string } +} + +export const STRUCTURED_HANDOFF_PROVIDER_CASES: StructuredHandoffProviderCase[] = [ + { provider: 'codex', accountHome: { variable: 'CODEX_HOME', pathName: 'codex-home' } }, + { + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', pathName: 'claude-home' } + } +] + +export function structuredHandoffTestProcess(now: number, spawnToken: string, pid: number) { + return { hostId: 'local', pid, processStartTimeMs: now - 1_000, spawnToken } +} + +export function structuredHandoffTestLink(input: { + provider: 'claude' | 'codex' + fence: number + id: string + now: number + claudeSessionId: string + codexThreadId: string +}) { + return { + linkId: input.id, + handle: + input.provider === 'claude' + ? ({ + provider: 'claude' as const, + sessionId: input.claudeSessionId, + leafUuid: input.id.startsWith('native-link') ? 'tui-exit-leaf' : 'current-leaf' + } as const) + : ({ provider: 'codex' as const, threadId: input.codexThreadId } as const), + origin: 'resumed' as const, + mintedAtFence: input.fence, + observedAt: input.now + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts new file mode 100644 index 00000000000..28bea9452db --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-requests.ts @@ -0,0 +1,52 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffRequest +} from '../../../shared/agent-session-wire' + +export type StructuredHandoffTestRequestOptions = { + action?: 'start' | 'cancel-queued' | 'retry' + operationId?: string +} + +export class StructuredHandoffTestRequests { + private operations = 0 + + constructor( + private readonly now: number, + private readonly sessionId: string, + private readonly readFence: () => number + ) {} + + reset(): void { + this.operations = 0 + } + + operationId(): string { + this.operations += 1 + return `${this.now}-${this.operations.toString(16).padStart(32, '0')}` + } + + request( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + options: StructuredHandoffTestRequestOptions = {} + ): AgentSessionHandoffRequest { + const action = options.action ?? 'start' + const fields = { direction, mode, action } + return { + envelope: { + sessionId: this.sessionId, + clientOperationId: options.operationId ?? this.operationId(), + expectedRuntimeFence: this.readFence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId: this.sessionId, + fields + }) + }, + ...fields + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts new file mode 100644 index 00000000000..2b8f2ac9b2c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -0,0 +1,77 @@ +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredTuiOwner = { + terminal: { handle: string; tabId: string; paneKey: string; ptyId: string } + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink + transcriptPath?: string +} + +export type StructuredAgentSessionHandoffTransport = { + hostLabel: string + launchTui(input: { + record: AgentSessionRecord + fence: number + spawnToken: string + }): Promise + reproveTuiOwner(input: { + record: AgentSessionRecord + owner: StructuredTuiOwner + }): Promise + recoverTuiOwner(record: AgentSessionRecord): Promise + stopRecoveredOwner(record: AgentSessionRecord): Promise + waitForTuiExit( + owner: StructuredTuiOwner, + persistHandle: (link: AgentSessionProviderHandleLink) => Promise + ): Promise<{ transcriptPath?: string }> + waitForTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> + tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' + stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise +} + +export type StructuredAgentSessionHandoffDeps = { + store: AgentSessionRecordStore + claimKeyId: string + transport?: StructuredAgentSessionHandoffTransport + session: (sessionId: string) => { journal: AgentSessionJournal; fence: number } + suspendNative: (sessionId: string) => Promise + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise + importTuiHistory: (input: { + sessionId: string + fence: number + transcriptPath?: string + }) => Promise + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + schedule: (sessionId: string, task: () => Promise) => Promise + now: () => number +} + +export type StructuredAgentSessionHandoffFlowContext = { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + enterPreparing: ( + record: AgentSessionRecord, + operationId: string, + direction: 'to-tui' | 'to-native' + ) => Promise + publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void + requireRecord: (sessionId: string) => AgentSessionRecord +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts new file mode 100644 index 00000000000..76f64f907c1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -0,0 +1,874 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + reserveStoredAgentSessionHandoffOwner, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import { createStructuredAgentSessionHandoffTestCoordinator } from './structured-agent-session-handoff-test-coordinator' +import { + STRUCTURED_HANDOFF_PROVIDER_CASES, + structuredHandoffTestLink, + structuredHandoffTestProcess, + type StructuredHandoffProviderCase +} from './structured-agent-session-handoff-test-identities' +import { + StructuredHandoffTestRequests, + type StructuredHandoffTestRequestOptions +} from './structured-agent-session-handoff-test-requests' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-handoff' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' + +let providerCase: StructuredHandoffProviderCase + +let root: string +let store: AgentSessionRecordStore +let journal: Awaited> +let coordinator: StructuredAgentSessionHandoffCoordinator +let statuses: AgentSessionHandoffStatus[] +let tuiIdle: boolean +let tuiReadiness: 'idle' | 'exited' | null +let importFailure: Error | null +let nativeAcquireFailure: Error | null +let launchTui: ReturnType> +let waitForTuiExit: ReturnType< + typeof vi.fn +> +let waitForTuiIdleOrExit: ReturnType< + typeof vi.fn +> +let reproveTuiOwner: ReturnType< + typeof vi.fn +> +let stopFailedTuiLaunch: ReturnType< + typeof vi.fn> +> +let acquireNativeStop: ReturnType Promise>> +let acquireNativeCalls: number +let stopRecoveredOwner: ReturnType< + typeof vi.fn +> +const requests = new StructuredHandoffTestRequests( + NOW, + SESSION, + () => store.getRecord(SESSION)?.lease.runtimeFence ?? 1 +) + +const operationId = (): string => requests.operationId() + +function request( + direction: Parameters[0], + mode: Parameters[1], + options: StructuredHandoffTestRequestOptions = {} +): AgentSessionHandoffRequest { + return requests.request(direction, mode, options) +} + +function submit(params: AgentSessionHandoffRequest) { + return coordinator.request('client-1', params) +} + +function process(spawnToken: string, pid: number) { + return structuredHandoffTestProcess(NOW, spawnToken, pid) +} + +function link(fence: number, id: string) { + return structuredHandoffTestLink({ + provider: providerCase.provider, + fence, + id, + now: NOW, + claudeSessionId: CLAUDE_SESSION, + codexThreadId: THREAD + }) +} + +async function establishNativeOwner(): Promise { + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: providerCase.provider, + accountHome: { + variable: providerCase.accountHome.variable, + path: join(root, providerCase.accountHome.pathName) + }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'native-initial', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: process('native-initial', 4100), + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { ...link(fence, 'initial-link'), origin: 'created' }, + now: NOW + }) +} + +function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { + handle: 'term-tui', + tabId: 'tab-tui', + paneKey: 'tab-tui:leaf-tui', + ptyId: 'pty-tui' + }, + process: process(spawnToken, 4200), + link: link(fence, `tui-link-${fence}`), + transcriptPath: join(root, 'rollout.jsonl') + } +} + +async function acquireNative(input: { + sessionId: string + fence: number + spawnToken: string +}): Promise { + acquireNativeCalls += 1 + if (providerCase.provider === 'claude') { + const chain = store.getRecord(input.sessionId)?.providerHandleChain ?? [] + if ( + chain.some( + (entry) => entry.handle.provider === 'claude' && entry.handle.leafUuid === 'tui-exit-leaf' + ) + ) { + expect(chain.at(-1)?.handle).toEqual({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + leafUuid: 'tui-exit-leaf' + }) + } + } + if (nativeAcquireFailure) { + const error = nativeAcquireFailure + nativeAcquireFailure = null + throw error + } + await store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: process(input.spawnToken, 4300 + acquireNativeCalls), + now: NOW + }) + return store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: link(input.fence, `native-link-${input.fence}`), + now: NOW + }) +} + +function createCoordinator(): StructuredAgentSessionHandoffCoordinator { + return createStructuredAgentSessionHandoffTestCoordinator({ + store, + journal, + sessionId: SESSION, + provider: providerCase.provider, + claudeSessionId: CLAUDE_SESSION, + codexThreadId: THREAD, + now: NOW, + launchTui, + reproveTuiOwner, + recoverTuiOwner: async (record) => { + const owner = makeTuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ) + return { ...owner, process: record.lease.ownerProcess ?? owner.process } + }, + stopRecoveredOwner, + waitForTuiExit, + waitForTuiIdleOrExit, + tuiStatus: () => (tuiIdle ? 'idle' : 'busy'), + stopFailedTuiLaunch, + acquireNative, + acquireNativeStop: (turnId) => acquireNativeStop(turnId), + takeImportFailure: () => { + if (importFailure) { + const error = importFailure + importFailure = null + return error + } + return null + }, + statuses + }) +} + +async function appendStatus(state: 'running' | 'completed'): Promise { + await journal.appendItem( + { provider: 'orca', clientMessageId: 'turn-status' }, + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } }, + { fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 } + ) +} + +async function waitForPhase(phase: AgentSessionHandoffStatus['phase']): Promise { + await vi.waitFor(() => expect(coordinator.status(SESSION).phase).toBe(phase)) +} + +async function moveToTui(): Promise { + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui' })) +} + +async function setup(): Promise { + root = await mkdtemp(join(tmpdir(), `orca-${providerCase.provider}-handoff-`)) + requests.reset() + statuses = [] + tuiIdle = true + tuiReadiness = 'idle' + importFailure = null + nativeAcquireFailure = null + acquireNativeCalls = 0 + stopRecoveredOwner = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await establishNativeOwner() + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: providerCase.provider, + providerHandle: + providerCase.provider === 'claude' + ? { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: 'current-leaf' } + : { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) + waitForTuiExit = vi.fn(async (owner, persistHandle) => { + if (owner.link.handle.provider === 'claude') { + await persistHandle({ + ...owner.link, + linkId: `tui-exit-${owner.link.mintedAtFence}`, + handle: { ...owner.link.handle, leafUuid: 'tui-exit-leaf' } + }) + } + return { transcriptPath: owner.transcriptPath } + }) + waitForTuiIdleOrExit = vi.fn(async () => tuiReadiness) + reproveTuiOwner = vi.fn(async ({ record, owner }) => { + expect(record.lease.ownerProcess).toEqual(owner.process) + if (record.lease.provenHandleLinkId !== null) { + expect(record.lease.provenHandleLinkId).toBe(owner.link.linkId) + } + return owner + }) + stopFailedTuiLaunch = vi.fn(async () => undefined) + acquireNativeStop = vi.fn(async () => true) + coordinator = createCoordinator() +} + +async function teardown(): Promise { + await coordinator.drain() + await rm(root, { recursive: true, force: true }) +} + +describe.each(STRUCTURED_HANDOFF_PROVIDER_CASES)( + '$provider structured session ownership handoff', + (currentProvider) => { + beforeEach(async () => { + providerCase = currentProvider + await setup() + }) + + afterEach(teardown) + + it('completes native to TUI to native with one owner and journal continuity', async () => { + await journal.appendItem( + providerCase.provider === 'claude' + ? { provider: 'claude', sessionId: CLAUDE_SESSION, uuid: 'native-turn' } + : { provider: 'codex', threadId: THREAD, turnId: 'native-turn', ordinal: 0 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'from native' }] }, + { fence: 1 } + ) + await moveToTui() + const forwardRecord = store.getRecord(SESSION)! + expect(forwardRecord.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native' })) + + expect(reproveTuiOwner).toHaveBeenCalledWith({ + record: expect.objectContaining({ + lease: expect.objectContaining({ + ownerProcess: forwardRecord.lease.ownerProcess, + provenHandleLinkId: forwardRecord.lease.provenHandleLinkId + }) + }), + owner: expect.objectContaining({ + process: forwardRecord.lease.ownerProcess, + link: expect.objectContaining({ linkId: forwardRecord.lease.provenHandleLinkId }) + }) + }) + + const messages = journal.snapshot().items.filter((item) => item.body.kind === 'message') + expect(messages).toHaveLength(2) + expect(new Set(messages.map((item) => item.itemId)).size).toBe(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('queues a busy native turn, supports cancel, and starts only after idle', async () => { + await appendStatus('running') + expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + expect( + await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' })) + ).toMatchObject({ ok: true }) + await appendStatus('completed') + await new Promise((resolve) => setTimeout(resolve, 200)) + expect(launchTui).not.toHaveBeenCalled() + + await appendStatus('running') + expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + await appendStatus('completed') + await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce()) + }) + + it('durably replays a completed queued cancellation after restart', async () => { + await appendStatus('running') + expect(await submit(request('to-tui', 'after-turn'))).toMatchObject({ ok: true }) + const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' }) + expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: false }) + await coordinator.drain() + + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + coordinator = createCoordinator() + + expect(await submit(cancellation)).toMatchObject({ ok: true, replayed: true }) + expect(launchTui).not.toHaveBeenCalled() + }) + + it('replays a duplicate active handoff without launching a second owner', async () => { + let finishLaunch!: () => void + launchTui.mockImplementationOnce( + ({ fence, spawnToken }) => + new Promise((resolve) => { + finishLaunch = () => resolve(makeTuiOwner(fence, spawnToken)) + }) + ) + const operation = operationId() + const first = request('to-tui', 'now', { operationId: operation }) + expect(await submit(first)).toMatchObject({ ok: true, replayed: false }) + await vi.waitFor(() => expect(launchTui).toHaveBeenCalledOnce()) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving' + }) + expect(coordinator.status(SESSION).owner).not.toBe('tui') + expect(await submit(first)).toMatchObject({ ok: true, replayed: true }) + finishLaunch() + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + expect(launchTui).toHaveBeenCalledOnce() + }) + + it('replays the same completed handoff operation after the coordinator restarts', async () => { + const operation = operationId() + const first = request('to-tui', 'now', { operationId: operation }) + expect(await submit(first)).toMatchObject({ ok: true, replayed: false }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + await coordinator.drain() + + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + coordinator = createCoordinator() + expect(await submit(first)).toMatchObject({ ok: true, replayed: true }) + expect(launchTui).toHaveBeenCalledOnce() + }) + + it('continues a persisted preparing stage after restart instead of stranding it', async () => { + const operation = operationId() + const first = request('to-tui', 'now', { operationId: operation }) + await store.admitOperation({ + callerKey: 'client-1', + operationId: operation, + fingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff.operation', + sessionId: SESSION, + fields: { direction: 'to-tui' } + }), + now: NOW + }) + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + await coordinator.drain() + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + coordinator = createCoordinator() + expect(await submit(first)).toMatchObject({ ok: true, replayed: true }) + expect(launchTui).toHaveBeenCalledOnce() + }) + + it('finishes a live new-owner-proving stage after restart', async () => { + const operation = operationId() + let record = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + record = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + operationId: operation, + now: NOW + }) + const spawnToken = 'restarted-tui' + record = await reserveStoredAgentSessionHandoffOwner(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId: operation, + claimKeyId: 'key-1', + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: record.lease.runtimeFence, + process: process(spawnToken, 4400), + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + }) + + it('clears stale recovery after re-proving the live TUI owner', async () => { + await moveToTui() + const record = store.getRecord(SESSION)! + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + phase: 'idle', + stage: null, + terminal: { handle: 'term-tui' } + }) + expect(reproveTuiOwner).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + + it('recovers native ownership when a stressed TUI recovery process is gone', async () => { + await moveToTui() + const record = store.getRecord(SESSION)! + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + reproveTuiOwner.mockRejectedValueOnce(new Error('The owning terminal was killed.')) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION, + lease: expect.objectContaining({ runtimeKind: 'tui' }) + }) + ) + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'native', + phase: 'idle', + stage: null + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + + it('rejects cancellation once a queued handoff is no longer pending', async () => { + expect( + await submit(request('to-tui', 'after-turn', { action: 'cancel-queued' })) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) + expect(coordinator.status(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) + }) + + it('durably replays a queued-cancellation refusal as a refusal', async () => { + const cancellation = request('to-tui', 'after-turn', { action: 'cancel-queued' }) + expect(await submit(cancellation)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + coordinator = createCoordinator() + + expect(await submit(cancellation)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + expect(launchTui).not.toHaveBeenCalled() + }) + + it('requires acknowledged cancellation before stopping a native turn', async () => { + await appendStatus('running') + expect(await submit(request('to-tui', 'stop-turn'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('tui')) + expect(acquireNativeStop).toHaveBeenCalledWith('turn-1') + expect(acquireNativeStop.mock.invocationCallOrder[0]).toBeLessThan( + launchTui.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('fires a queued TUI transfer from the turn-completion waiter', async () => { + await moveToTui() + tuiIdle = false + tuiReadiness = null + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + expect(waitForTuiExit).not.toHaveBeenCalled() + tuiReadiness = 'idle' + await vi.waitFor(() => expect(waitForTuiExit).toHaveBeenCalledOnce()) + expect(tuiIdle).toBe(false) + expect(waitForTuiIdleOrExit).toHaveBeenCalled() + }) + + it('completes a mobile-originated queued reverse after the owning TUI exits', async () => { + await moveToTui() + tuiIdle = false + tuiReadiness = null + const reverse = request('to-native', 'after-turn') + + expect(await coordinator.request('mobile-device-a', reverse)).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + tuiReadiness = 'exited' + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + + expect(reproveTuiOwner).not.toHaveBeenCalled() + expect(waitForTuiExit).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('re-proves the current TUI handle before accepting its exit and importing history', async () => { + await moveToTui() + const reverse = request('to-native', 'now') + expect(await submit(reverse)).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + + expect(reproveTuiOwner).toHaveBeenCalledOnce() + expect(reproveTuiOwner.mock.invocationCallOrder[0]).toBeLessThan( + waitForTuiExit.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('does not close or import when the current TUI handle cannot be re-proved', async () => { + await moveToTui() + reproveTuiOwner.mockRejectedValueOnce(new Error('provider handle changed')) + expect(await submit(request('to-native', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(waitForTuiExit).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('rolls a stale reverse handle back to one durable TUI owner and retries', async () => { + await moveToTui() + waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale')) + const retryOperation = operationId() + + expect( + await submit(request('to-native', 'now', { operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + operationId: retryOperation, + error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + + expect( + await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + expect(waitForTuiExit).toHaveBeenCalledTimes(2) + }) + + it('keeps one recoverable owner when a queued reverse auto-fire fails', async () => { + await moveToTui() + tuiIdle = false + tuiReadiness = null + waitForTuiExit.mockRejectedValueOnce(new Error('terminal_handle_stale')) + + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + expect(coordinator.status(SESSION).phase).toBe('queued') + tuiReadiness = 'idle' + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + error: { recoverableOwner: 'tui', details: 'terminal_handle_stale' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + + it('recovers native ownership when TUI launch or proof fails', async () => { + launchTui.mockRejectedValueOnce(new Error('resume proof failed')) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'native', + error: { recoverableOwner: 'native' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('stops a launched but uncommitted TUI before recovering native', async () => { + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + ...makeTuiOwner(fence, spawnToken), + link: link(fence + 1, 'bad-proof') + })) + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.runtimeKind).toBe('native') + }) + + it('keeps a recoverable native owner after repeated transfers and a dead TUI relaunch', async () => { + for (let cycle = 0; cycle < 2; cycle += 1) { + await moveToTui() + expect(await submit(request('to-native', 'after-turn'))).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + } + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + ...makeTuiOwner(fence, spawnToken), + link: link(fence + 1, 'dead-relaunch') + })) + + expect(await submit(request('to-tui', 'now'))).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(stopFailedTuiLaunch).toHaveBeenCalledOnce() + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'native', + error: { recoverableOwner: 'native' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + }) + + it('requires manual recovery when a failed TUI launch cannot be proven stopped', async () => { + launchTui.mockImplementationOnce(async ({ fence, spawnToken }) => ({ + ...makeTuiOwner(fence, spawnToken), + link: link(fence + 1, 'bad-proof') + })) + stopFailedTuiLaunch.mockRejectedValueOnce(new Error('exit unproved')) + const failedOperation = operationId() + expect( + await submit(request('to-tui', 'now', { operationId: failedOperation })) + ).toMatchObject({ + ok: true + }) + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'none', + stage: 'manual-recovery', + error: { recoverableOwner: 'none' } + }) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('manual-recovery') + await coordinator.restore(SESSION) + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'none', + stage: 'manual-recovery' + }) + expect( + await submit(request('to-tui', 'now', { action: 'retry', operationId: failedOperation })) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_conflict' } }) + }) + + it('retains the stopped TUI session for retry when native resume fails', async () => { + await moveToTui() + importFailure = new Error('history unavailable') + const retryOperation = operationId() + expect( + await submit(request('to-native', 'after-turn', { operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + operationId: retryOperation, + error: { recoverableOwner: 'tui' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'released', + handoffStage: 'old-owner-stopped' + }) + + expect( + await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + expect(waitForTuiExit).toHaveBeenCalledOnce() + }) + + it('abandons a failed native acquisition and retries from the stopped TUI', async () => { + await moveToTui() + nativeAcquireFailure = new Error('native proof unavailable') + const retryOperation = operationId() + expect( + await submit(request('to-native', 'after-turn', { operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await waitForPhase('failed') + + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + operationId: retryOperation, + error: { recoverableOwner: 'tui' } + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'released', + handoffStage: 'old-owner-stopped' + }) + + coordinator = createCoordinator() + expect(coordinator.status(SESSION)).toMatchObject({ + owner: 'tui', + direction: 'to-native', + phase: 'failed', + operationId: retryOperation + }) + expect( + await submit(request('to-native', 'now', { action: 'retry', operationId: retryOperation })) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) + expect(waitForTuiExit).toHaveBeenCalledOnce() + expect(acquireNativeCalls).toBe(2) + }) + + it('publishes every durable stage so concurrent clients see the same transfer', async () => { + await moveToTui() + expect(statuses.map((status) => status.stage)).toEqual( + expect.arrayContaining(['preparing', 'old-owner-stopped', 'new-owner-proving', null]) + ) + expect(statuses.filter((status) => status.phase === 'switching')).toHaveLength(3) + }) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts new file mode 100644 index 00000000000..6a15bbdd285 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -0,0 +1,316 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import { + admitStructuredHandoffRequest, + refuseAdmittedStructuredHandoff, + replayedStructuredHandoffRefusal, + structuredHandoffRetryIsAdmissible +} from './structured-agent-session-handoff-admission' +import { createStructuredHandoffFlowContext } from './structured-agent-session-handoff-flow-context' +import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' +import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { + structuredHandoffRefusal as refusal, + structuredHandoffSuccess +} from './structured-agent-session-handoff-result' +import { + failedStructuredHandoffStatus, + idleStructuredHandoffStatus, + structuredSessionHasPendingPrompt, + structuredTuiStatus, + switchingStructuredHandoffStatus +} from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffCoordinator { + private readonly statuses = new Map() + private readonly queue = new StructuredAgentSessionHandoffQueue() + private readonly tuiOwners = new Map() + private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard + private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner + + constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { + this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) + this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ + deps, + operationGuard: this.operationGuard, + flowContext: () => this.flowContext(), + fail: (params, error) => this.fail(params, error) + }) + } + + status(sessionId: string): AgentSessionHandoffStatus { + return ( + this.statuses.get(sessionId) ?? idleStructuredHandoffStatus(this.requireRecord(sessionId)) + ) + } + + drain = (): Promise => this.flowRunner.drain() + + async restore(sessionId: string): Promise { + await restoreStructuredAgentSessionHandoff( + { + deps: this.deps, + requireRecord: (id) => this.requireRecord(id), + flowContext: () => this.flowContext(), + retainOwner: (id, owner) => this.tuiOwners.set(id, owner), + setStatus: (id, status) => this.setStatus(id, status) + }, + sessionId + ) + } + + async request( + callerKey: string, + params: AgentSessionHandoffRequest + ): Promise> { + const record = this.requireRecord(params.envelope.sessionId) + const currentStatus = this.statuses.get(record.sessionId) + const admission = await admitStructuredHandoffRequest({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + record, + ...(currentStatus ? { status: currentStatus } : {}) + }) + if (admission.decision === 'replay') { + const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome) + if (replayedRefusal) { + return { ok: false, refusal: replayedRefusal } + } + return this.success(record.sessionId, true) + } + if (admission.decision === 'refused') { + return { ok: false, refusal: admission.refusal } + } + const { fingerprint } = admission + const action = params.action ?? 'start' + if (action === 'cancel-queued') { + if (currentStatus?.phase !== 'queued' || currentStatus.direction !== params.direction) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'No matching queued handoff exists.' + ) + } + this.queue.cancel(record.sessionId) + this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + await this.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: record.sessionId } + }) + return this.success(record.sessionId, false) + } + if (!this.deps.transport) { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Agent TUI handoff is unavailable on this host.' + ) + } + if (action === 'retry') { + if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer retryable.' + ) + } + this.begin(callerKey, params, null, fingerprint) + return this.success(record.sessionId, false) + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + `The ${expectedOwner} runtime does not own this session.` + ) + } + if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'Resolve the pending question or approval before switching.' + ) + } + const turnId = activeStructuredAgentSessionTurnId( + this.deps.session(record.sessionId).journal.snapshot().items + ) + const tuiOwner = this.tuiOwners.get(record.sessionId) + const busy = + expectedOwner === 'native' + ? turnId !== null + : structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle' + if (busy && params.mode === 'now') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'The current turn must finish before switching.' + ) + } + if (busy && params.mode === 'after-turn') { + this.queueAfterTurn(callerKey, params, fingerprint) + return this.success(record.sessionId, false) + } + if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Exit the agent terminal after this turn to continue in chat.' + ) + } + this.begin(callerKey, params, turnId, fingerprint) + return this.success(record.sessionId, false) + } + + private async refuseAdmitted( + callerKey: string, + params: AgentSessionHandoffRequest, + code: AgentSessionWireRefusal['code'], + message: string + ): Promise> { + return refuseAdmittedStructuredHandoff({ + deps: this.deps, + callerKey, + params, + refusal: refusal(code, message) + }) + } + + private success(sessionId: string, replayed: boolean) { + return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) + } + + private queueAfterTurn( + callerKey: string, + params: AgentSessionHandoffRequest, + fingerprint: string + ): void { + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + this.setStatus(sessionId, { + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: this.deps.transport?.hostLabel + }) + const tuiOwner = this.tuiOwners.get(sessionId) + this.queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId( + this.deps.session(sessionId).journal.snapshot().items + ) + } + tuiReadiness = tuiOwner + ? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + return tuiReadiness !== null + }, + () => { + const next = { ...params, mode: 'now' as const } + this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited') + } + ) + } + + private begin( + callerKey: string, + params: AgentSessionHandoffRequest, + turnId: string | null, + fingerprint: string, + tuiAlreadyExited = false + ): void { + this.flowRunner.begin({ + callerKey, + params, + turnId, + fingerprint, + tuiAlreadyExited + }) + } + + private flowContext(): StructuredAgentSessionHandoffFlowContext { + return createStructuredHandoffFlowContext({ + deps: this.deps, + owner: (sessionId) => this.tuiOwners.get(sessionId), + retainOwner: (sessionId, owner) => this.tuiOwners.set(sessionId, owner), + releaseOwner: (sessionId) => void this.tuiOwners.delete(sessionId), + setStatus: (sessionId, status) => this.setStatus(sessionId, status), + enterPreparing: (record, operationId, direction) => + this.enterPreparing(record, operationId, direction), + publishStage: (record, direction) => this.publishStage(record, direction), + requireRecord: (sessionId) => this.requireRecord(sessionId) + }) + } + + private async enterPreparing( + record: AgentSessionRecord, + operationId: string, + direction: 'to-tui' | 'to-native' + ): Promise { + const prepared = await setStoredAgentSessionHandoffStage(this.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: 'preparing', + handoffOperationId: operationId, + now: this.deps.now() + }) + this.publishStage(prepared, direction) + } + + private publishStage(record: AgentSessionRecord, direction: 'to-tui' | 'to-native'): void { + this.setStatus( + record.sessionId, + switchingStructuredHandoffStatus(record, direction, this.deps.transport?.hostLabel) + ) + } + + private fail(params: AgentSessionHandoffRequest, error: unknown): void { + const record = this.requireRecord(params.envelope.sessionId) + this.setStatus( + record.sessionId, + failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) + ) + } + + private setStatus(sessionId: string, status: AgentSessionHandoffStatus): void { + this.statuses.set(sessionId, status) + this.deps.publish(sessionId, status) + } + + private requireRecord(sessionId: string): AgentSessionRecord { + const record = this.deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return record + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts new file mode 100644 index 00000000000..f3d59caa6a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -0,0 +1,133 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { journalIdentityFor } from './structured-agent-session-attach' +import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +type HostHandoffAccess = { + session: (sessionId: string) => StructuredAgentSessionHostSession + eventSink: (sessionId: string) => DeferredStructuredAgentSessionEventSink + flush: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + subscribers: AgentSessionSubscribers + now: () => number +} + +export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator + +export async function refreshRecoverableStructuredHandoffStatus( + handoff: StructuredAgentSessionHostHandoff, + store: StructuredAgentSessionHostDeps['store'], + sessionId: string +) { + const record = store.getRecord(sessionId) + if (record && canRestoreLiveTuiOwner(record)) { + await handoff.restore(sessionId) + } + return handoff.status(sessionId) +} + +export function createStructuredAgentSessionHostHandoff( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess +): StructuredAgentSessionHostHandoff { + return new StructuredAgentSessionHandoffCoordinator({ + store: deps.store, + claimKeyId: deps.claimKeyId, + ...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}), + session: host.session, + suspendNative: async (sessionId) => { + await deps.adapter.closeSession?.(sessionId) + await host.flush(sessionId) + host.eventSink(sessionId).unbind() + }, + acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input), + acquireNativeStop: async (sessionId, turnId, fence) => + (await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled, + importTuiHistory: (input) => importTuiHistory(deps, host, input), + publish: (sessionId, status) => { + const session = host.session(sessionId) + const fence = deps.store.getRecord(sessionId)?.lease.runtimeFence ?? session.fence + host.subscribers.handoff(sessionId, session.journal, fence, status) + }, + schedule: host.serialize, + now: host.now + }) +} + +async function importTuiHistory( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; transcriptPath?: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + const head = record?.providerHandleChain.at(-1) + if (!record || !head) { + throw new Error('agent_session_identity_required') + } + const providerSessionId = + head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + const imported = await importLegacyTranscriptIntoJournal({ + journal: session.journal, + agent: head.handle.provider, + sessionId: providerSessionId, + fence: input.fence, + ...(input.transcriptPath ? { options: { filePath: input.transcriptPath } } : {}) + }) + if (!imported.ok) { + throw new Error(imported.error) + } + host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) +} + +async function acquireNativeHandoffOwner( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; spawnToken: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const eventSink = host.eventSink(input.sessionId) + eventSink.unbind() + await eventSink.drained() + const acquired = await deps.adapter.acquire({ + identity: journalIdentityFor(record, session.params), + fence: input.fence, + spawnToken: input.spawnToken, + events: eventSink.sink + }) + let proved: AgentSessionRecord + try { + await deps.store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: acquired.process, + now: host.now() + }) + proved = await deps.store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: acquired.link, + now: host.now() + }) + } catch (error) { + await deps.adapter.releaseAcquisition?.({ sessionId: input.sessionId }) + throw error + } + session.fence = proved.lease.runtimeFence + eventSink.bind({ + journal: session.journal, + fence: proved.lease.runtimeFence, + publish: () => host.subscribers.publish(input.sessionId, session.journal) + }) + host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence) + return proved +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts new file mode 100644 index 00000000000..e2f74f719f3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -0,0 +1,76 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + createDeferredStructuredAgentSessionEventSink, + type DeferredStructuredAgentSessionEventSink +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' + +export class StructuredAgentSessionHostRuntimeState { + private readonly eventSinks = new Map() + private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer + + constructor( + private readonly deps: StructuredAgentSessionHostDeps, + onLeaseRenewed?: (record: AgentSessionRecord) => Promise + ) { + this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ + store: deps.store, + probe: (record) => this.probeRecord(record), + now: () => deps.now?.() ?? Date.now(), + ...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}), + onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) + }) + } + + startLeaseRenewal(): void { + this.leaseRenewer.start() + } + + stopLeaseRenewal(): void { + this.leaseRenewer.stop() + } + + eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { + const existing = this.eventSinks.get(sessionId) + if (existing) { + return existing + } + const created = createDeferredStructuredAgentSessionEventSink({ + onError: (error) => this.deps.onEventSinkError?.({ sessionId, error }) + }) + this.eventSinks.set(sessionId, created) + return created + } + + discardEventSink(sessionId: string): void { + this.eventSinks.delete(sessionId) + } + + flushEventSink(sessionId: string): Promise { + return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve() + } + + async flushAllEventSinks(): Promise { + await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained())) + } + + probeOwner(sessionId: string): Promise { + const record = this.deps.store.getRecord(sessionId) + if (!record || record.lease.ownerProcess === null) { + return Promise.resolve({ outcome: 'reservation-unused' }) + } + return this.probeRecord(record) + } + + probeRecord(record: AgentSessionRecord): Promise { + return ( + this.deps.probeOwner?.(record) ?? + Promise.resolve({ + outcome: 'indeterminate', + reason: 'This host cannot probe structured session owners.' + }) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts new file mode 100644 index 00000000000..c2947891c43 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -0,0 +1,15 @@ +export type StructuredAgentSessionTab = { + sessionId: string + workspaceId: string + agent: 'codex' +} + +export function listStructuredAgentSessionTabs( + sessions: ReadonlyMap +): StructuredAgentSessionTab[] { + return [...sessions.entries()].map(([sessionId, session]) => ({ + sessionId, + workspaceId: session.params.location.workspaceId, + agent: 'codex' + })) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts new file mode 100644 index 00000000000..69e6d029d60 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -0,0 +1,63 @@ +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +export const HOST_TEST_NOW = 1_800_000_000_000 +export const HOST_TEST_SESSION = 'session-alpha' +export const HOST_TEST_THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +export const HOST_TEST_LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +let operations = 0 + +export function resetHostTestOperationIds(): void { + operations = 0 +} + +export function hostTestOperationId(): string { + operations += 1 + return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}` +} + +export function hostTestMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export function hostTestAttachParams( + expectedRuntimeFence: number | null, + overrides: Partial = {} +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence, + payloadFingerprint: '0'.repeat(64) + }, + location: HOST_TEST_LOCATION, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: HOST_TEST_THREAD }, + ...overrides + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts new file mode 100644 index 00000000000..afbbd2a9278 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -0,0 +1,27 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +export type StructuredAgentSessionCaller = { callerKey: string } + +export type StructuredAgentSessionHostSession = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number +} + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + mintSpawnToken?: () => string + now?: () => number + onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index edbc0114db3..39206eeace0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -1,19 +1,11 @@ -// The host against a real record store and a real journal, with only the -// provider adapter stubbed — admission, idempotency, and the journal writes are -// exactly the ones that ship. - import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent @@ -28,35 +20,20 @@ import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { attachFingerprintFields } from './structured-agent-session-attach' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { StructuredAgentSessionHost } from './structured-agent-session-host' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const LOCATION: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' const CALLER = { callerKey: 'client-1' } -let operations = 0 - -/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ -function operationId(): string { - operations += 1 - return `${NOW}-${operations.toString(16).padStart(32, '0')}` -} - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - function envelope( method: string, fields: Record, @@ -64,9 +41,7 @@ function envelope( ): AgentSessionMutationEnvelope { return { sessionId: SESSION, - clientOperationId: operationId(), - // A mutation names the fence it believes it is writing under; only attach - // may leave it null. + clientOperationId: hostTestOperationId(), expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, payloadFingerprint: computeAgentSessionPayloadFingerprint({ method, @@ -78,44 +53,11 @@ function envelope( } function attachParams(overrides: Partial = {}): AgentSessionAttachParams { - const params: AgentSessionAttachParams = { - envelope: { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: null, - payloadFingerprint: '0'.repeat(64) - }, - location: LOCATION, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - runtimeKind: 'native', - providerHandle: { kind: 'codex', threadId: THREAD }, - ...overrides - } - return { - ...params, - envelope: { - ...params.envelope, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: params.envelope.sessionId, - fields: attachFingerprintFields(params) - }) - } - } + return hostTestAttachParams(null, overrides) } -/** `ensure`: the same transition as create, from a fence the client already holds. */ function ensureParams(fence: number): AgentSessionAttachParams { - return attachParams({ - envelope: { - sessionId: SESSION, - clientOperationId: operationId(), - expectedRuntimeFence: fence, - payloadFingerprint: '0'.repeat(64) - } - }) + return hostTestAttachParams(fence) } let root: string @@ -185,7 +127,7 @@ async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revis beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) - operations = 0 + resetHostTestOperationIds() ordinal = 0 acquire = vi.fn(async ({ fence }) => ({ process: { @@ -197,8 +139,6 @@ beforeEach(async () => { link: { linkId: `link-${fence}`, handle: { provider: 'codex', threadId: THREAD }, - // A restarted host re-proves the thread it inherited; only the first - // owner of a session may claim to have created it. origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', mintedAtFence: fence, observedAt: NOW @@ -221,6 +161,7 @@ beforeEach(async () => { }) afterEach(async () => { + await host.flushAllStreamedEvents() await rm(root, { recursive: true, force: true }) }) @@ -228,7 +169,6 @@ describe('attach', () => { it('reserves the lease, spawns through the adapter, and opens the journal', async () => { const result = await host.attach(CALLER, attachParams()) expect(result).toMatchObject({ ok: true, replayed: false }) - expect(host.hasSession(SESSION)).toBe(true) const record = store.getRecord(SESSION) expect(record?.lease.ownerProcess?.pid).toBe(4242) expect(record?.lease.handoffStage).toBeNull() @@ -409,7 +349,7 @@ describe('attach', () => { describe('send', () => { it('writes the submission before dispatching and resolves it accepted', async () => { await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const result = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body @@ -427,7 +367,7 @@ describe('send', () => { it('settles a thrown dispatch as unknown, never as a rejection', async () => { await attach() dispatch.mockRejectedValueOnce(new Error('socket closed')) - const body = message('add a retry') + const body = hostTestMessage('add a retry') const result = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body @@ -437,7 +377,7 @@ describe('send', () => { it('replays a retried send from the journal without dispatching twice', async () => { await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const params = { envelope: envelope('agentSession.send', { body }), body } await host.send(CALLER, params) const retry = await host.send(CALLER, params) @@ -450,7 +390,7 @@ describe('send', () => { dispatch .mockRejectedValueOnce(new Error('socket closed')) .mockImplementationOnce(async () => accepted()) - const body = message('possibly delivered') + const body = hostTestMessage('possibly delivered') const params = { envelope: envelope('agentSession.send', { body }), body } const first = await host.send(CALLER, params) @@ -472,7 +412,7 @@ describe('send', () => { it('refuses a stale fence and hands back the current one', async () => { const record = await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const result = await host.send(CALLER, { envelope: envelope( 'agentSession.send', @@ -489,7 +429,7 @@ describe('send', () => { it('does not let a refused call leave a ledger row that replays past the fence', async () => { const record = await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const params = { envelope: envelope( 'agentSession.send', @@ -507,7 +447,7 @@ describe('send', () => { }) it('refuses any mutation against a session this host has not attached', async () => { - const body = message('add a retry') + const body = hostTestMessage('add a retry') expect( await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) @@ -738,7 +678,7 @@ describe('restart', () => { it('restores durable journals for read-only history without acquiring a provider', async () => { await attach() - const body = message('persisted conversation') + const body = hostTestMessage('persisted conversation') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) acquire.mockClear() @@ -753,13 +693,40 @@ describe('restart', () => { expect(acquire).not.toHaveBeenCalled() }) + it('clears stale TUI recovery when restart successfully reacquires the native owner', async () => { + await attach() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + runtimeKind: 'tui', + handoffStage: 'manual-recovery' + } + })) + await reboot(async () => ({ outcome: 'pid-absent' })) + acquire.mockClear() + + await host.restoreReadableSessions() + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + owner: 'native', + phase: 'idle', + stage: null + }) + }) + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) - // Nothing proved the previous process dead, so the lease routes to manual - // recovery rather than letting a second writer in behind it. expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } @@ -776,7 +743,6 @@ describe('restart', () => { await reboot(probe) await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') - // One unlucky startup must not strand the session for this host's lifetime. const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) expect(reattached).toMatchObject({ ok: true }) expect(probe).toHaveBeenCalledTimes(2) @@ -792,7 +758,7 @@ describe('subscribe', () => { sessionId: SESSION, emit: (event) => events.push(event) }) - const body = message('add a retry') + const body = hostTestMessage('add a retry') await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) expect(events[0]?.type).toBe('snapshot') @@ -807,7 +773,7 @@ describe('subscribe', () => { it('resumes from a client cursor with only the rows it missed', async () => { await attach() - const body = message('add a retry') + const body = hostTestMessage('add a retry') const first = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body @@ -823,10 +789,9 @@ describe('subscribe', () => { emit: (event) => events.push(event), cursor: first.cursor }) - // Nothing new since that cursor: a resume is silent, not a redundant replay. - expect(events).toHaveLength(0) + expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } }) - const second = message('and a timeout') + const second = hostTestMessage('and a timeout') await host.send(CALLER, { envelope: envelope('agentSession.send', { body: second }), body: second @@ -850,7 +815,7 @@ describe('subscribe', () => { sessionId: SESSION, emit: (event) => events.push(event) }) - const body = message('survive subscriber failure') + const body = hostTestMessage('survive subscriber failure') const result = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 45f6a297892..4ce1002bbc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -1,24 +1,16 @@ -// The structured agent-session host: one place where the lease, the journal, -// and the provider adapter meet. -// -// Every mutating call takes the same route — recompute the fingerprint, admit -// through the durable operation ledger, check the lease, then act — so no -// method can grow its own admission rules. Calls against one session are -// serialized; the journal's own queue orders writes, but admission has to be -// single-file too or two clients could both read one prompt as pending. +// Structured agent-session host: where the lease, journal, and provider adapter meet. +// Mutations share one durable admission path and serialize per session. import { randomUUID } from 'node:crypto' -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' -import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { - AgentSessionExecutionLocation, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionAttachResult, AgentSessionCancelResult, AgentSessionHistoryRequest, AgentSessionHistoryResult, + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, AgentSessionMutationEnvelope, AgentSessionMutationResult, AgentSessionOptionResult, @@ -27,16 +19,9 @@ import type { AgentSessionSendResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach } from './structured-agent-session-attach-flow' -import { - createDeferredStructuredAgentSessionEventSink, - type DeferredStructuredAgentSessionEventSink -} from './structured-agent-session-event-sink' import { admitAndRunAgentSessionMutation, AGENT_SESSION_NOT_ATTACHED, @@ -53,59 +38,59 @@ import { } from './structured-agent-session-mutation-plans' import { AgentSessionSubscribers, - type AgentSessionSubscriberEmit + type AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' import * as providerRouting from './structured-agent-session-provider-routing' +import { + createStructuredAgentSessionHostHandoff, + refreshRecoverableStructuredHandoffStatus, + type StructuredAgentSessionHostHandoff +} from './structured-agent-session-host-handoff' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' -export type StructuredAgentSessionCaller = { - /** Stable per-client identity; scopes the operation ledger and records who - * answered a prompt. */ - callerKey: string -} - -export type StructuredAgentSessionHostDeps = { - store: AgentSessionRecordStore - adapter: StructuredAgentSessionAdapter - journalRoot: string - /** Key id the host's claims are minted under. */ - claimKeyId: string - probeOwner?: (record: AgentSessionRecord) => Promise - mintSpawnToken?: () => string - now?: () => number - /** A journal append the provider streamed that could not be written. Unset - * drops it: the alternative is throwing inside the provider's notification - * callback, which would take the connection down over one lost row. */ - onEventSinkError?: (input: { sessionId: string; error: unknown }) => void -} - -type SessionState = { - journal: AgentSessionJournal - params: AgentSessionAttachParams - fence: number -} +export type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps +} from './structured-agent-session-host-types' export class StructuredAgentSessionHost { - private readonly sessions = new Map() + private readonly sessions = new Map() private readonly subscribers = new AgentSessionSubscribers() private readonly tasks = new StructuredAgentSessionTaskQueue() - private readonly eventSinks = new Map() + private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: (sessionId: string) => Promise + private readonly handoffs: StructuredAgentSessionHostHandoff constructor(readonly deps: StructuredAgentSessionHostDeps) { + this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) => + this.restoreRenewedHandoff(record.sessionId) + ) this.reconcileLeases = createRestartReconciler({ store: deps.store, - probe: (record) => this.probeRecord(record), + probe: (record) => this.runtimeState.probeRecord(record), now: () => this.now() }) + this.handoffs = createStructuredAgentSessionHostHandoff(deps, { + session: (sessionId) => this.requireSession(sessionId), + eventSink: (sessionId) => this.runtimeState.eventSinkFor(sessionId), + flush: (sessionId) => this.flushStreamedEvents(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + subscribers: this.subscribers, + now: this.now + }) + this.runtimeState.startLeaseRenewal() } private now = (): number => this.deps.now?.() ?? Date.now() - hasSession(sessionId: string): boolean { - return this.sessions.has(sessionId) - } + hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) supportsCreate(location: AgentSessionExecutionLocation, agent: string): boolean { return providerRouting.adapterSupportsAgentSessionCreate(this.deps.adapter, location, agent) @@ -137,7 +122,8 @@ export class StructuredAgentSessionHost { ), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: (sessionId) => this.sessions.has(sessionId), - onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored) + onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored), + restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) }) } @@ -145,10 +131,14 @@ export class StructuredAgentSessionHost { return this.tasks.serialize(sessionId, task) } - /** - * `create` and `ensure` in one: a null expected fence means the session must - * not exist yet, any other value is a compare-and-swap against the lease. - */ + private restoreRenewedHandoff(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + if (this.sessions.has(sessionId)) { + await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + } + }) + } + attach( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams @@ -159,7 +149,7 @@ export class StructuredAgentSessionHost { if (unreconciled) { return refuseAgentSessionMutation(unreconciled) } - const eventSink = this.eventSinkFor(sessionId) + const eventSink = this.runtimeState.eventSinkFor(sessionId) const attached = await performAttach({ store: this.deps.store, adapter: this.deps.adapter, @@ -174,7 +164,7 @@ export class StructuredAgentSessionHost { spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), claimKeyId: this.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, - probe: await this.probeOwner(sessionId) + probe: await this.runtimeState.probeOwner(sessionId) }, callerKey: caller.callerKey, params, @@ -190,9 +180,6 @@ export class StructuredAgentSessionHost { } else { this.subscribers.publish(sessionId, attached.journal) } - // Bound last: everything the provider streamed while the journal was - // opening lands after the attach result the client is about to read, - // never interleaved with it. eventSink.bind({ journal: attached.journal, fence, @@ -200,66 +187,23 @@ export class StructuredAgentSessionHost { }) } }) - // A refused attach that never reached a journal leaves the sink holding - // writes nothing will ever drain. if (!attached.ok && !this.sessions.has(sessionId)) { eventSink.close() - this.eventSinks.delete(sessionId) + this.runtimeState.discardEventSink(sessionId) } return attached }) return this.tasks.trackAttach(attaching) } - /** Settles every row the provider streamed. Callers that must read the - * journal immediately after provider activity await this first. */ flushStreamedEvents(sessionId: string): Promise { - return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve() + return this.runtimeState.flushEventSink(sessionId) } - /** Settles final provider rows after every child has stopped producing them. */ async flushAllStreamedEvents(): Promise { - // An acquired provider can still be opening its journal; bind before draining. + this.runtimeState.stopLeaseRenewal() await this.tasks.drainAttaches() - await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained())) - } - - /** One sink per session, rebound on every attach. A single identity lets an - * adapter hold it across a re-acquire without noticing the new journal. */ - private eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { - const existing = this.eventSinks.get(sessionId) - if (existing) { - return existing - } - const created = createDeferredStructuredAgentSessionEventSink({ - onError: (error) => this.deps.onEventSinkError?.({ sessionId, error }) - }) - this.eventSinks.set(sessionId, created) - return created - } - - /** - * Liveness of whatever currently owns the session. Never client-asserted: a - * client that could claim the owner is gone could evict a live one. Without a - * host prober the answer is `indeterminate`, which routes to manual recovery - * rather than a silent takeover. - */ - private probeOwner(sessionId: string): Promise { - const record = this.deps.store.getRecord(sessionId) - if (!record || record.lease.ownerProcess === null) { - return Promise.resolve({ outcome: 'reservation-unused' }) - } - return this.probeRecord(record) - } - - private probeRecord(record: AgentSessionRecord): Promise { - return ( - this.deps.probeOwner?.(record) ?? - Promise.resolve({ - outcome: 'indeterminate', - reason: 'This host cannot probe structured session owners.' - }) - ) + await this.runtimeState.flushAllEventSinks() } send( @@ -325,11 +269,21 @@ export class StructuredAgentSessionHost { ) } - /** - * Paged read. Throws `agent_session_ownership_unknown` for a session this host - * has not attached — a reset would tell the client to reload something that - * does not exist here. - */ + requestHandoff( + caller: StructuredAgentSessionCaller, + params: AgentSessionHandoffRequest + ): Promise> { + this.requireSession(params.envelope.sessionId) + return this.handoffs.request(caller.callerKey, params) + } + + async handoffStatus(sessionId: string): Promise { + this.requireSession(sessionId) + return this.serialize(sessionId, () => + refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + ) + } + history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request) const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence @@ -339,20 +293,20 @@ export class StructuredAgentSessionHost { return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence } } - subscribe(input: { - id: string - sessionId: string - emit: AgentSessionSubscriberEmit - cursor?: AgentJournalCursor - }): () => void { + subscribe(input: AgentSessionSubscribeInput): () => void { const session = this.requireSession(input.sessionId) const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0 - return this.subscribers.open({ ...input, journal: session.journal, fence }) + return this.subscribers.open({ + ...input, + journal: session.journal, + fence, + handoff: this.handoffs.status(input.sessionId) + }) } unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) - private requireSession(sessionId: string): SessionState { + private requireSession(sessionId: string): StructuredAgentSessionHostSession { const session = this.sessions.get(sessionId) if (!session) { throw new Error(AGENT_SESSION_NOT_ATTACHED.code) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts new file mode 100644 index 00000000000..17bfff24c8b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -0,0 +1,135 @@ +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 { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' + +const NOW = 1_800_000_000_000 +const roots: string[] = [] + +async function liveStore(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: 'session-renewal', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: root }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-renewal', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-renewal' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'link-renewal', + handle: { provider: 'codex', threadId: 'thread-renewal' }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + return store +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured agent-session lease renewal', () => { + it('drives renewal on the production interval', async () => { + vi.useFakeTimers() + const store = await liveStore() + let now = NOW + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }), + now: () => now + }) + try { + renewer.start() + now += 10_000 + await vi.advanceTimersByTimeAsync(10_000) + await vi.waitFor(() => + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(now) + ) + } finally { + renewer.stop() + vi.useRealTimers() + } + }) + + it('renews every live owner only after re-proving its child identity', async () => { + const store = await liveStore() + const probe = vi.fn(async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + })) + const onRenewed = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + now: () => NOW + 10_000, + onRenewed + }) + + await renewer.renewNow() + + expect(probe).toHaveBeenCalledOnce() + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + expect(onRenewed).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-renewal' }) + ) + }) + + it('stops extending the lease when child proof is no longer sufficient', async () => { + const store = await liveStore() + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), + now: () => NOW + 10_000, + onError + }) + + await renewer.renewNow() + + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-renewal', + error: expect.any(Error) + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts new file mode 100644 index 00000000000..dcec74d1d1a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -0,0 +1,75 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from '../../runtime/agent-session-record-store' + +const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) + +export class StructuredAgentSessionLeaseRenewer { + private timer: ReturnType | null = null + private running = false + + constructor( + private readonly input: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + now: () => number + onRenewed?: (record: AgentSessionRecord) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + intervalMs?: number + } + ) {} + + start(): void { + if (this.timer) { + return + } + this.timer = setInterval(() => void this.renewNow(), this.input.intervalMs ?? RENEW_INTERVAL_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + async renewNow(): Promise { + if (this.running) { + return + } + this.running = true + try { + await Promise.all( + this.input.store + .listRecords() + .filter( + (record) => + !record.lease.unreconciled && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null + ) + .map((record) => this.renewRecord(record)) + ) + } finally { + this.running = false + } + } + + private async renewRecord(record: AgentSessionRecord): Promise { + try { + const renewed = await this.input.store.renewLease({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + childProbe: await this.input.probe(record), + now: this.input.now() + }) + await this.input.onRenewed?.(renewed) + } catch (error) { + this.input.onError?.({ sessionId: record.sessionId, error }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index a5f19cc8e55..7599fe134ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,4 +1,7 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { loadJournal } from '../agent-session-journal/journal-open' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' @@ -49,7 +52,11 @@ export async function restoreStructuredAgentSessionRead( export function attachParamsForRecord( record: AgentSessionRecord, - input: { clientOperationId: string; expectedRuntimeFence: number } + input: { + clientOperationId: string + expectedRuntimeFence: number + runtimeKind?: AgentSessionOwnerRuntimeKind + } ): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { @@ -62,7 +69,7 @@ export function attachParamsForRecord( provider: record.provider, agent: record.provider, accountHome: record.accountHome, - runtimeKind: record.lease.runtimeKind + runtimeKind: input.runtimeKind ?? record.lease.runtimeKind } return { ...params, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 9543fea4441..9a45c53f5bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -18,6 +18,7 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + restoreHandoff: (sessionId: string) => Promise }): Promise { await Promise.all( input.records.map(async ({ sessionId }) => { @@ -26,10 +27,12 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { if ( !unreconciled && current?.lease.claimStatus === 'released' && + current.lease.handoffStage === null && (await input.resume( attachParamsForRecord(current, { clientOperationId: input.operationId(), - expectedRuntimeFence: current.lease.runtimeFence + expectedRuntimeFence: current.lease.runtimeFence, + runtimeKind: 'native' }) )) ) { @@ -48,6 +51,9 @@ export async function restoreStructuredAgentSessionsOnRestart(input: { input.onReadable(sessionId, restored) } }) + if (input.hasSession(sessionId)) { + await input.restoreHandoff(sessionId) + } }) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index a3b4e3484ee..a5262de99b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -8,11 +8,20 @@ import type { AgentJournalCursor, AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { + AgentSessionHandoffStatus, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { projectJournalBatch } from './agent-session-journal-batch' export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void +export type AgentSessionSubscribeInput = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor +} type Subscriber = { id: string @@ -34,6 +43,7 @@ export class AgentSessionSubscribers { fence: number emit: AgentSessionSubscriberEmit cursor?: AgentJournalCursor + handoff?: AgentSessionHandoffStatus }): () => void { const snapshot = input.journal.snapshot() const subscriber: Subscriber = { @@ -48,13 +58,14 @@ export class AgentSessionSubscribers { this.bySession.set(input.sessionId, session) if (input.cursor) { - this.deliver(subscriber, input.journal) + this.deliver(subscriber, input.journal, input.handoff) } else { this.emit(subscriber, { type: 'snapshot', sessionId: input.sessionId, snapshot, - fence: input.fence + fence: input.fence, + ...(input.handoff ? { handoff: input.handoff } : {}) }) subscriber.cursor = snapshot.cursor } @@ -107,11 +118,29 @@ export class AgentSessionSubscribers { } } + handoff( + sessionId: string, + journal: AgentSessionJournal, + fence: number, + handoff: AgentSessionHandoffStatus + ): void { + const snapshot = journal.snapshot() + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'snapshot', sessionId, snapshot, fence, handoff }) + subscriber.cursor = snapshot.cursor + subscriber.fence = fence + } + } + private subscribers(sessionId: string): Subscriber[] { return [...(this.bySession.get(sessionId)?.values() ?? [])] } - private deliver(subscriber: Subscriber, journal: AgentSessionJournal): void { + private deliver( + subscriber: Subscriber, + journal: AgentSessionJournal, + handoff?: AgentSessionHandoffStatus + ): void { const since = journal.readSince(subscriber.cursor) const snapshot = journal.snapshot() if (!since.ok) { @@ -120,12 +149,27 @@ export class AgentSessionSubscribers { sessionId: subscriber.sessionId, reset: since.reset, snapshot, - fence: subscriber.fence + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) }) subscriber.cursor = snapshot.cursor return } if (since.rows.length === 0) { + if (handoff) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: snapshot.cursor, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: subscriber.fence, + handoff + }) + } return } const projected = projectJournalBatch({ @@ -139,7 +183,8 @@ export class AgentSessionSubscribers { sessionId: subscriber.sessionId, reset: projected.reset, snapshot, - fence: subscriber.fence + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) }) subscriber.cursor = snapshot.cursor return @@ -147,7 +192,9 @@ export class AgentSessionSubscribers { this.emit(subscriber, { type: 'batch', sessionId: subscriber.sessionId, - batch: projected.batch + batch: projected.batch, + ...(handoff ? { fence: subscriber.fence } : {}), + ...(handoff ? { handoff } : {}) }) subscriber.cursor = projected.batch.cursor } diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.test.ts b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts new file mode 100644 index 00000000000..d19869ff342 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { applyAgentSessionRestartAdjudication } from './agent-session-lease-transitions' + +describe('agent session handoff restart transitions', () => { + it('preserves the stopped owner and operation for durable retry', () => { + const handoffOperationId = '1800000000000-00000000000000000000000000000001' + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + unreconciled: true + }) + ) + + const next = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'reservation-unused' }, + now: 1_800_000_001_000 + }) + + expect(next.lease).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + unreconciled: false + }) + }) +}) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts new file mode 100644 index 00000000000..255102cde33 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -0,0 +1,155 @@ +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { assertFence, reserveAgentSessionOwner, withLease } from './agent-session-lease-transitions' + +export function stopAgentSessionOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: record.lease.runtimeFence + 1, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'observed process exit', + observedAt: args.now + } + }) +} + +export function rollbackAgentSessionHandoffPreparation(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: args.now + }) +} + +export function stopRecoveringTuiOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + (record.lease.handoffStage !== 'recovering' && + record.lease.handoffStage !== 'manual-recovery') || + record.lease.runtimeKind !== 'tui' || + record.lease.handoffOperationId !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: record.lease.runtimeFence + 1, + handoffStage: 'old-owner-stopped', + handoffOperationId: args.operationId, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'recovery proved TUI process exit', + observedAt: args.now + } + }) +} + +export function reserveAgentSessionHandoffOwner(args: { + record: AgentSessionRecord + expectedFence: number + runtimeKind: AgentSessionOwnerRuntimeKind + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs: number +}): AgentSessionRecord { + return reserveAgentSessionOwner({ + record: args.record, + expectedFence: args.expectedFence, + probe: { outcome: 'reservation-unused' }, + reservation: { + runtimeKind: args.runtimeKind, + spawnToken: args.spawnToken, + claimKeyId: args.claimKeyId, + handoffOperationId: args.operationId, + leaseTtlMs: args.leaseTtlMs, + now: args.now + } + }).record +} + +export function abandonAgentSessionHandoffAttempt(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + recoverableRuntimeKind: AgentSessionOwnerRuntimeKind + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.handoffOperationId !== args.operationId + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeKind: args.recoverableRuntimeKind, + runtimeFence: record.lease.runtimeFence + 1, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'handoff launch attempt stopped', + observedAt: args.now + } + }) +} diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts new file mode 100644 index 00000000000..8d27065d4a0 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-record-transitions.ts @@ -0,0 +1,92 @@ +import type { AgentSessionHandoffStage } from '../../shared/agent-session-record' +import { + abandonAgentSessionHandoffAttempt, + reserveAgentSessionHandoffOwner, + rollbackAgentSessionHandoffPreparation, + stopAgentSessionOwnerForHandoff, + stopRecoveringTuiOwnerForHandoff +} from './agent-session-handoff-lease-transitions' +import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from './agent-session-record-store' + +export function setStoredAgentSessionHandoffStage( + store: AgentSessionRecordStore, + args: { + sessionId: string + fence: number + stage: AgentSessionHandoffStage | null + handoffOperationId: string | null + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + setAgentSessionHandoffStage({ ...args, record }) + ) +} + +export function stopStoredAgentSessionOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopAgentSessionOwnerForHandoff({ ...args, record }) + ) +} + +export function rollbackStoredAgentSessionHandoffPreparation( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + rollbackAgentSessionHandoffPreparation({ ...args, record }) + ) +} + +export function stopStoredRecoveringTuiOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopRecoveringTuiOwnerForHandoff({ ...args, record }) + ) +} + +export function reserveStoredAgentSessionHandoffOwner( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + runtimeKind: 'native' | 'tui' + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs?: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + reserveAgentSessionHandoffOwner({ + ...args, + record, + leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS + }) + ) +} + +export function abandonStoredAgentSessionHandoffAttempt( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + operationId: string + recoverableRuntimeKind: 'native' | 'tui' + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + abandonAgentSessionHandoffAttempt({ ...args, record }) + ) +} diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 01ec81dbc25..3cc134ba9e0 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -6,7 +6,6 @@ * observed process identity, and a proved provider handle — in that order, at one fence. */ -import { isDeepStrictEqual } from 'node:util' import { adjudicateAgentSessionRestart, evaluateAgentSessionAcquisition, @@ -24,6 +23,7 @@ import type { AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' export type AgentSessionReservation = { runtimeKind: AgentSessionOwnerRuntimeKind @@ -34,11 +34,14 @@ export type AgentSessionReservation = { now: number } -function withLease(record: AgentSessionRecord, lease: AgentSessionLease): AgentSessionRecord { +export function withLease( + record: AgentSessionRecord, + lease: AgentSessionLease +): AgentSessionRecord { return { ...record, lease, updatedAt: lease.lastRenewedAt } } -function assertFence(lease: AgentSessionLease, fence: number): void { +export function assertFence(lease: AgentSessionLease, fence: number): void { if (lease.runtimeFence !== fence) { throw new Error('agent_session_checkpoint_stale') } @@ -47,13 +50,6 @@ function assertFence(lease: AgentSessionLease, fence: number): void { } } -export function agentSessionReconciliationTargetMatches( - current: AgentSessionRecord, - probed: AgentSessionRecord -): boolean { - return isDeepStrictEqual(current, probed) -} - /** * Compare-and-swap reservation. Writes the intent at fence + 1 before any process exists, so the * loser of a concurrent swap is refused and never spawns. @@ -242,6 +238,23 @@ export function applyAgentSessionRestartAdjudication(args: { now: number }): AgentSessionRecord { const { record } = args + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.ownerProcess === null + ) { + return withLease(record, { + ...record.lease, + unreconciled: false, + lastRenewedAt: args.now + }) + } + if ( + record.lease.handoffStage === 'preparing' || + record.lease.handoffStage === 'new-owner-proving' + ) { + return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now) + } const adjudication = adjudicateAgentSessionRestart({ lease: record.lease, probe: args.probe, diff --git a/src/main/runtime/agent-session-process-identity-probe.test.ts b/src/main/runtime/agent-session-process-identity-probe.test.ts index 57e0361baa7..5f310a2a6ee 100644 --- a/src/main/runtime/agent-session-process-identity-probe.test.ts +++ b/src/main/runtime/agent-session-process-identity-probe.test.ts @@ -154,13 +154,18 @@ describe('owner identity probe', () => { ).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) }) - it('returns no start time on Windows rather than guessing one', async () => { - await expect(readProcessStartTimeMs(process.pid, 'win32')).resolves.toBeNull() + it('reads a CIM-backed start time on Windows when running there', async () => { + const observed = await readProcessStartTimeMs(process.pid, 'win32') + expect(observed === null).toBe(process.platform !== 'win32') }) it('reads a start time for the current process on this platform', async () => { const observed = await readProcessStartTimeMs(process.pid) - if (process.platform === 'linux' || process.platform === 'darwin') { + if ( + process.platform === 'linux' || + process.platform === 'darwin' || + process.platform === 'win32' + ) { expect(observed).not.toBeNull() expect(Math.abs((observed as number) - (Date.now() - process.uptime() * 1000))).toBeLessThan( 60_000 diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 8d63fd485ac..93d5427f3aa 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -74,9 +74,26 @@ async function readDarwinProcessStartTimeMs(pid: number): Promise } } +async function readWindowsProcessStartTimeMs(pid: number): Promise { + try { + const script = + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` + + 'if ($p) { $p.CreationDate.ToUniversalTime().ToString("o") }' + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { timeout: PROCESS_START_TIME_TIMEOUT_MS, windowsHide: true } + ) + const parsed = Date.parse(stdout.trim()) + return Number.isFinite(parsed) ? parsed : null + } catch { + return null + } +} + /** - * Windows returns null: reading a start time needs a CIM query Orca does not make here, so a - * Windows child without a token echo qualifies for manual recovery only. + * Process start time is the cross-platform PID-reuse guard when no provider hook can echo the + * spawn token back to the owner probe. */ export async function readProcessStartTimeMs( pid: number, @@ -88,6 +105,9 @@ export async function readProcessStartTimeMs( if (platform === 'darwin') { return readDarwinProcessStartTimeMs(pid) } + if (platform === 'win32') { + return readWindowsProcessStartTimeMs(pid) + } return null } diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts index 35294d01fbf..5c289eaba2e 100644 --- a/src/main/runtime/agent-session-pty-write-gate.test.ts +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -91,6 +91,49 @@ describe('admission through the store', () => { }) }) + it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)( + 'refuses TUI writes while handoff stage %s is active', + (handoffStage) => { + publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage })) + const admission = gate.admit(PTY_ID) + expect(admission.admitted).toBe(false) + if (!admission.admitted) { + expect(admission.refusal).toMatchObject({ + code: 'agent_session_conflict', + handoffStage, + ownerRuntimeKind: 'tui' + }) + } + } + ) + + it('admits only the reserved TUI proof token before ownership', () => { + publish( + agentSessionLeaseFixture({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: null, + reservedSpawnToken: 'proof-token' + }) + ) + expect(gate.admit(PTY_ID).admitted).toBe(false) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + expect( + gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' }) + ).toBe(false) + }) + + it('refuses proof input after ownership is live', () => { + publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' })) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( + false + ) + }) + it('refuses when the record vanished from the store', () => { const admission = gate.admit(PTY_ID) expect(admission.admitted).toBe(false) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts index 95faf8cb2b3..da513cde706 100644 --- a/src/main/runtime/agent-session-pty-write-gate.ts +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -69,6 +69,22 @@ export class AgentSessionPtyWriteGate { return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId)) } + /** Narrow pre-ownership input for the reserved TUI's provider identity probe. */ + admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean { + const binding = this.binding(ptyId) + const lease = binding?.record?.lease + return Boolean( + binding?.sessionId === authority.sessionId && + binding.record?.sessionId === authority.sessionId && + lease?.runtimeKind === 'tui' && + lease.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + lease.ownerProcess === null && + lease.reservedSpawnToken === authority.spawnToken && + !lease.unreconciled + ) + } + /** Re-check a write already in flight against the fence it was admitted under. */ readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission { if (admitted.sessionId === null && !this.enforcing) { diff --git a/src/main/runtime/agent-session-reconciliation-target.ts b/src/main/runtime/agent-session-reconciliation-target.ts new file mode 100644 index 00000000000..03fd77be7dd --- /dev/null +++ b/src/main/runtime/agent-session-reconciliation-target.ts @@ -0,0 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function agentSessionReconciliationTargetMatches( + current: AgentSessionRecord, + probed: AgentSessionRecord +): boolean { + return isDeepStrictEqual(current, probed) +} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index e7bf796704c..74d6c6219a3 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -9,6 +9,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS, AgentSessionRecordStore @@ -754,7 +755,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { it('rejects a handoff stage change under a different operation id', async () => { const store = await open() await establishOwner(store) - await store.setHandoffStage({ + await setStoredAgentSessionHandoffStage(store, { sessionId: 'session-alpha', fence: 1, stage: 'preparing', @@ -762,7 +763,7 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { now: NOW }) await expect( - store.setHandoffStage({ + setStoredAgentSessionHandoffStage(store, { sessionId: 'session-alpha', fence: 1, stage: 'old-owner-stopped', diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index de69ee14dd8..feb5be204b0 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -10,8 +10,9 @@ */ import { - pruneAgentSessionOperationRows, agentSessionOperationKey, + pruneAgentSessionOperationRows, + settleAgentSessionOperation, type AgentSessionOperationDecision, type AgentSessionOperationOutcome, type AgentSessionOperationRow @@ -25,22 +26,20 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session- import { agentSessionScopeKey, type AgentSessionExecutionLocation, - type AgentSessionHandoffStage, type AgentSessionJournalCheckpoint, type AgentSessionRecord } from '../../shared/agent-session-record' import { applyAgentSessionRestartAdjudication, - agentSessionReconciliationTargetMatches, commitAgentSessionProcessIdentity, evictAgentSessionOwner, proveAgentSessionOwner, renewAgentSessionLease, - setAgentSessionHandoffStage, setAgentSessionJournalCheckpoint, type AgentSessionProcessIdentityCommit } from './agent-session-lease-transitions' import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' +import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' import { setAgentSessionReservationProcesslessProof, type AgentSessionReservationProcesslessProof @@ -238,14 +237,11 @@ export class AgentSessionRecordStore { return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) } - async setHandoffStage(args: { - sessionId: string - fence: number - stage: AgentSessionHandoffStage | null - handoffOperationId: string | null - now: number - }): Promise { - return this.mutate(args.sessionId, (record) => setAgentSessionHandoffStage({ ...args, record })) + async transitionHandoff( + sessionId: string, + transition: (record: AgentSessionRecord) => AgentSessionRecord + ): Promise { + return this.mutate(sessionId, transition) } async setJournalCheckpoint(args: { @@ -304,16 +300,12 @@ export class AgentSessionRecordStore { } async recordOperationOutcome(args: { - callerKey: string + callerKey?: string operationId: string outcome: AgentSessionOperationOutcome }): Promise { await this.transact(() => { - const key = agentSessionOperationKey(args.callerKey, args.operationId) - const row = this.state.operations.get(key) - if (row) { - this.state.operations.set(key, { ...row, outcome: args.outcome }) - } + this.state.operations = settleAgentSessionOperation(this.state.operations, args) }) } diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts new file mode 100644 index 00000000000..a18f55f333f --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' + +const NOW = 1_800_000_000_000 + +function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord { + return { + schemaVersion: 1, + sessionId: 'session-restart', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + lease: { + sessionId: 'session-restart', + runtimeKind: stage === 'preparing' ? 'native' : 'tui', + runtimeFence: 4, + handoffStage: stage, + provenHandleLinkId: null, + ownerProcess: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-restart' + }, + reservedSpawnToken: 'spawn-restart', + leaseDeadlineAt: NOW + 30_000, + lastRenewedAt: NOW, + handoffOperationId: 'handoff-op-1', + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: stage === 'preparing' ? 'live' : 'reserved', + unreconciled: true, + deathEvidence: null + }, + createdAt: NOW, + updatedAt: NOW + } +} + +describe('restarted handoff adjudication', () => { + it('continues a dead preparing owner at old-owner-stopped under the same operation', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('preparing'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1', + claimStatus: 'released', + ownerProcess: null + }) + }) + + it('rolls a dead proving target back to the source kind before retry', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('new-owner-proving'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1' + }) + }) +}) diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.ts new file mode 100644 index 00000000000..86a42d7dbc2 --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.ts @@ -0,0 +1,52 @@ +import { + adjudicateAgentSessionRestart, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function adjudicateRestartedAgentSessionHandoff( + record: AgentSessionRecord, + probe: AgentSessionOwnerProbe, + now: number +): AgentSessionRecord { + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe, + observedAt: now + }) + if (adjudication.disposition === 'readopt') { + return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) + } + if (adjudication.disposition !== 'evicted') { + return updateLease(record, { + ...record.lease, + handoffStage: 'recovering', + claimStatus: + adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + unreconciled: false, + lastRenewedAt: now + }) + } + const provingTarget = record.lease.handoffStage === 'new-owner-proving' + return updateLease(record, { + ...record.lease, + runtimeKind: provingTarget + ? record.lease.runtimeKind === 'native' + ? 'tui' + : 'native' + : record.lease.runtimeKind, + runtimeFence: adjudication.nextFence, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: now, + deathEvidence: adjudication.evidence + }) +} + +function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) { + return { ...record, lease, updatedAt: lease.lastRenewedAt } +} diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 7811359c947..859ff5ba34c 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises' +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, vi } from 'vitest' @@ -13,9 +13,16 @@ import type { openClaudeStreamJsonConnection } from '../claude/claude-stream-json-connection' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' -import { CLAUDE_SPAWN_TOKEN_ENV } from '../claude/claude-structured-owner-identity' +import { + CLAUDE_SPAWN_TOKEN_ENV, + claudeProviderHandleLink +} from '../claude/claude-structured-owner-identity' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' @@ -142,7 +149,7 @@ function ensureParams(fence: number) { }, provider: 'claude' as const, agent: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/accounts/claude' }, + accountHome: { variable: 'CLAUDE_CONFIG_DIR' as const, path: join(root, 'claude-home') }, runtimeKind: 'native' as const, providerHandle: { kind: 'claude' as const, @@ -169,10 +176,20 @@ function ensureParams(fence: number) { } } +function handoffParams(direction: 'to-native' | 'to-tui', fence: number) { + const fields = { direction, mode: 'now' as const, action: 'start' as const } + return { + envelope: envelope('agentSession.requestHandoff', fields, fence), + ...fields + } +} + let claude: ReturnType let root: string let dispatcher: RpcDispatcher let cleanups: Map void> +let tuiOwner: StructuredTuiOwner | null +let transcriptPath: string async function call(method: string, params: unknown): Promise { const replies: RpcResponse[] = [] @@ -237,8 +254,63 @@ function textOf(item: AgentJournalRenderItem): string { beforeEach(async () => { operations = 0 root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) + transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`) + await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true }) claude = fakeClaude() + tuiOwner = null cleanups = new Map() + const handoffTransport: StructuredAgentSessionHandoffTransport = { + hostLabel: 'Scripted Claude host', + launchTui: async ({ record, fence, spawnToken }) => { + const head = record.providerHandleChain.at(-1)?.handle + tuiOwner = { + terminal: { + handle: 'term-claude-tui', + tabId: 'tab-claude-tui', + paneKey: 'tab-claude-tui:leaf-claude-tui', + ptyId: 'pty-claude-tui' + }, + process: { + hostId: 'local', + pid: 7331, + processStartTimeMs: 100, + spawnToken + }, + link: claudeProviderHandleLink({ + sessionId: PROVIDER_SESSION, + leafUuid: head?.provider === 'claude' ? head.leafUuid : null, + resumed: true, + fence, + observedAt: Date.now() + }), + transcriptPath + } + return tuiOwner + }, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async () => { + if (!tuiOwner) { + throw new Error('scripted TUI owner missing') + } + return tuiOwner + }, + stopRecoveredOwner: async () => {}, + waitForTuiExit: async (owner, persistHandle) => { + await persistHandle( + claudeProviderHandleLink({ + sessionId: PROVIDER_SESSION, + leafUuid: 'tui-assistant', + resumed: true, + fence: owner.link.mintedAtFence, + observedAt: Date.now() + }) + ) + return { transcriptPath } + }, + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle', + stopFailedTuiLaunch: async () => {} + } const runtime = { getRuntimeId: () => 'runtime-1', getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), @@ -260,7 +332,8 @@ beforeEach(async () => { ANTHROPIC_AUTH_TOKEN: 'configured-token', ANTHROPIC_BASE_URL: 'https://gateway.example.test' }), - openClaudeConnection: claude.openConnection + openClaudeConnection: claude.openConnection, + handoffTransport }).then(() => undefined), registerSubscriptionCleanup: (id: string, dispose: () => void) => cleanups.set(id, dispose), cleanupSubscription: (id: string) => cleanups.get(id)?.(), @@ -306,7 +379,7 @@ describe('a structured Claude session over agentSession.*', () => { expect(claude.live().launch.env).toEqual({ ANTHROPIC_AUTH_TOKEN: 'configured-token', ANTHROPIC_BASE_URL: 'https://gateway.example.test', - CLAUDE_CONFIG_DIR: '/accounts/claude', + CLAUDE_CONFIG_DIR: join(root, 'claude-home'), [CLAUDE_SPAWN_TOKEN_ENV]: expect.any(String) }) const stream = await subscribe() @@ -398,4 +471,77 @@ describe('a structured Claude session over agentSession.*', () => { origin: 'resumed' }) }) + + it('completes a scripted native to TUI to native cycle with provider-history rehydration', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await writeFile( + transcriptPath, + [ + { + type: 'user', + uuid: 'native-user', + message: { role: 'user', content: [{ type: 'text', text: 'NATIVE_USER' }] } + }, + { + type: 'assistant', + uuid: 'native-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'NATIVE_ASSISTANT' }] } + }, + { + type: 'user', + uuid: 'tui-user', + message: { role: 'user', content: [{ type: 'text', text: 'TUI_USER' }] } + }, + { + type: 'assistant', + uuid: 'tui-assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'TUI_ASSISTANT' }] } + }, + { type: 'last-prompt', leafUuid: 'tui-assistant' } + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + ) + + await ok('agentSession.requestHandoff', handoffParams('to-tui', created.fence)) + const host = getStructuredAgentSessionHost()! + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + ) + expect(claude.connections[0]?.closed).toBe(true) + + const tuiFence = ( + host as unknown as { + deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } } + } + ).deps.store.getRecord(SESSION).lease.runtimeFence + await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence)) + await vi.waitFor(async () => + expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) + ) + + const frames = await subscribe() + const texts = itemsOf(frames).map(textOf).filter(Boolean) + expect(texts).toEqual( + expect.arrayContaining(['NATIVE_USER', 'NATIVE_ASSISTANT', 'TUI_USER', 'TUI_ASSISTANT']) + ) + expect(new Set(texts).size).toBe(texts.length) + expect(claude.connections).toHaveLength(2) + expect(claude.live().launch.args.slice(-2)).toEqual(['--resume', PROVIDER_SESSION]) + const record = ( + host as unknown as { + deps: { + store: { + getRecord: (id: string) => { + providerHandleChain: { handle: { provider: string; leafUuid?: string | null } }[] + } + } + } + } + ).deps.store.getRecord(SESSION) + expect(record.providerHandleChain.at(-1)?.handle).toMatchObject({ + provider: 'claude', + leafUuid: 'tui-assistant' + }) + }) }) diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts new file mode 100644 index 00000000000..f54a136f4e4 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { OrcaRuntimeService } from './orca-runtime' + +const { probeAgentSessionProcessIdentity, readStructuredTuiProcessIdentity } = vi.hoisted(() => ({ + probeAgentSessionProcessIdentity: vi.fn(), + readStructuredTuiProcessIdentity: vi.fn() +})) + +vi.mock('./structured-tui-process-identity', () => ({ readStructuredTuiProcessIdentity })) +vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ + ...(await importOriginal()), + probeAgentSessionProcessIdentity +})) + +const WORKTREE_ID = 'repo-1::/tmp/structured-handoff' + +function notifier(revealTerminalSession: ReturnType) { + return { + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + } +} + +describe('structured TUI launch tab binding', () => { + it('proves the published launch tab before returning its revealed renderer binding', async () => { + let explicitStatus: { + state: 'working' | 'done' + prompt: string + receivedAt: number + stateStartedAt: number + paneKey: string + terminalHandle: string + } | null = null + const revealTerminalSession = vi.fn( + (_worktreeId: string, _options: { tabId?: string; leafId?: string; ptyId?: string }) => + Promise.resolve({ tabId: 'tab-renderer' }) + ) + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + disabledTuiAgents: [], + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {} + }) + } as never, + undefined, + { + getAgentStatusSnapshot: () => (explicitStatus ? [explicitStatus as never] : []) + } + ) + runtime.setNotifier(notifier(revealTerminalSession) as never) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + resolveTerminalWorkspaceLaunchScope(): Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + markLocalWorkspaceTrustedForAgent(): void + waitForTerminal(): Promise + waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }> + waitForStructuredTuiPtyExit(): Promise + handles: Map< + string, + { + rendererGraphEpoch: number + tabId: string + leafId: string + } + > + graphStatus: 'ready' + } + internal.resolveTerminalWorkspaceLaunchScope = vi.fn(async () => ({ + id: WORKTREE_ID, + path: '/tmp/structured-handoff', + connectionId: null, + repo: null, + folderWorkspace: null + })) + internal.markLocalWorkspaceTrustedForAgent = vi.fn() + const waitForTerminal = vi.fn(async () => ({})) + internal.waitForTerminal = waitForTerminal + const waitForStructuredTuiProof = vi.fn(async () => { + const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + expect(snapshot.tabs).toContainEqual( + expect.objectContaining({ + type: 'terminal', + parentTabId: expect.any(String), + leafId: expect.any(String), + ptyId: 'pty-structured', + terminal: expect.any(String) + }) + ) + expect(revealTerminalSession).not.toHaveBeenCalled() + return { transcriptPath: '/tmp/rollout.jsonl' } + }) + internal.waitForStructuredTuiProof = waitForStructuredTuiProof + const waitForStructuredTuiPtyExit = vi.fn(async () => {}) + internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit + readStructuredTuiProcessIdentity.mockResolvedValue({ + hostId: 'local', + pid: 4243, + processStartTimeMs: 10, + spawnToken: 'spawn-token' + }) + probeAgentSessionProcessIdentity.mockResolvedValue({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }) + + const transport = internal.createStructuredAgentSessionHandoffTransport() + const owner = await transport.launchTui({ + record: { + sessionId: 'session-1', + location: { workspaceId: WORKTREE_ID, executionHostId: 'local' }, + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 } + ] + } as never, + fence: 3, + spawnToken: 'spawn-token' + }) + + const reveal = revealTerminalSession.mock.calls[0]?.[1] as { + tabId: string + leafId: string + } + expect(owner.terminal).toMatchObject({ + tabId: 'tab-renderer', + paneKey: `${reveal.tabId}:${reveal.leafId}`, + ptyId: 'pty-structured' + }) + expect(waitForTerminal).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( + revealTerminalSession.mock.invocationCallOrder[0]! + ) + + Object.assign(internal.handles.get(owner.terminal.handle)!, { + rendererGraphEpoch: -1, + tabId: 'tab-retired', + leafId: 'leaf-retired' + }) + internal.graphStatus = 'ready' + + explicitStatus = { + state: 'working', + prompt: '', + receivedAt: Date.now(), + stateStartedAt: Date.now(), + paneKey: owner.terminal.paneKey, + terminalHandle: owner.terminal.handle + } + expect(transport.tuiStatus(owner)).toBe('busy') + await expect( + transport.waitForTuiIdleOrExit(owner, new AbortController().signal) + ).resolves.toBeNull() + + explicitStatus = { ...explicitStatus, state: 'done', receivedAt: Date.now() } + expect(transport.tuiStatus(owner)).toBe('idle') + await expect(transport.waitForTuiIdleOrExit(owner, new AbortController().signal)).resolves.toBe( + 'idle' + ) + + const pty = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-structured')! + pty.launchToken = null + const persistedRecord = { + sessionId: 'session-1', + providerHandleChain: [{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }], + lease: { ownerProcess: owner.process, provenHandleLinkId: owner.link.linkId } + } as never + + const rebound = await transport.reproveTuiOwner({ record: persistedRecord, owner }) + expect(rebound.terminal).toMatchObject({ + ptyId: 'pty-structured', + tabId: owner.terminal.tabId, + paneKey: owner.terminal.paneKey + }) + expect(rebound.terminal.handle).not.toBe(owner.terminal.handle) + await transport.waitForTuiExit(rebound, async () => {}) + expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') + expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + + explicitStatus = null + pty.connected = false + await expect( + transport.waitForTuiIdleOrExit(rebound, new AbortController().signal) + ).resolves.toBe('exited') + await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined() + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 873dff5c1a7..df8942c5dec 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -74,6 +74,30 @@ import { import { ensureStructuredAgentSessionHost as installStructuredAgentSessionHost } from './structured-agent-session-runtime' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionProviderHandlesEqual } from '../../shared/agent-session-provider-handle' +import { completeClaudeTuiExit } from '../claude/claude-tui-exit' +import { + createClaudeTuiResumeLaunchBuilder, + type ClaudeTuiResumeLaunch +} from '../claude/claude-tui-resume-launch' +import { + proveClaudeTuiResume, + readClaudeTuiSessionStartEvidence +} from '../claude/claude-tui-resume-proof' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' +import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' +import { proveCodexTuiRollout } from '../codex/codex-tui-rollout-proof' +import { resolveSessionFilePath } from '../native-chat/session-file-resolver' +import { agentHookServer } from '../agent-hooks/server' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { getSystemCodexHomePath } from '../codex/codex-home-paths' import { @@ -119,7 +143,7 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { createHash, randomUUID } from 'node:crypto' -import { homedir } from 'node:os' +import { homedir, hostname } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' @@ -427,7 +451,10 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' import { buildAgentDraftLaunchPlan, buildAgentResumeStartupPlan, - buildAgentStartupPlan + buildAgentStartupPlan, + buildShellCommandFromArgv, + quoteStartupArg, + resolveStartupShell } from '../../shared/tui-agent-startup' import { repoIsRemote } from '../../shared/agent-launch-remote' import { @@ -1390,6 +1417,7 @@ type TerminalCreateOptions = { // Why: only the host-derived structured resume path may attach provider // identity; opaque terminal.create commands remain ordinary shells. agentSessionClaim?: AgentSessionExecutionClaim + structuredAgentSessionId?: string agentSessionCreateOperationId?: string signal?: AbortSignal // Why: idempotent create operations must retain their fence after the PTY @@ -1402,6 +1430,13 @@ type TerminalCreateOptions = { deferMobileSessionPublish?: boolean } +type StructuredAgentSessionHandoffAuthority = { + spawnToken: string + providerHome: string + sessionId: string + launch?: ClaudeTuiResumeLaunch +} + function mergeTerminalEnvDeletionKeys( first: readonly string[] | undefined, second: readonly string[] | undefined @@ -1705,12 +1740,18 @@ type RuntimePtyController = { } }): Promise<{ id: string + pid?: number incarnationId?: PtyIncarnationId wslDistro?: string stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean + writeAgentSessionProof?( + ptyId: string, + data: string, + authority: { sessionId: string; spawnToken: string } + ): boolean /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. * False on doubt (absent session, SSH-scoped id, non-daemon provider). */ @@ -9088,6 +9129,442 @@ export class OrcaRuntimeService { resolveClaudeLaunchEnv: () => { const settings = this.requireStore().getSettings() return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv) + }, + handoffTransport: this.createStructuredAgentSessionHandoffTransport() + }) + } + + private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: hostname(), + launchTui: async ({ record, fence, spawnToken }) => { + const head = record.providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_identity_required') + } + const agent = head.handle.provider + const providerSessionId = + head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + let claudeProof: + | { prove: () => ReturnType; dispose: () => void } + | undefined + let claudeLaunch: ClaudeTuiResumeLaunch | undefined + if (head.handle.provider === 'claude') { + const expectedTranscriptPath = await resolveSessionFilePath('claude', providerSessionId, { + claudeProjectsDir: join(record.accountHome.path, 'projects') + }) + if (!expectedTranscriptPath) { + throw new Error('The Claude transcript could not be resolved before TUI resume.') + } + claudeLaunch = await createClaudeTuiResumeLaunchBuilder({ + resolveWorkspacePath: async (workspaceId) => + (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + resolveEnv: () => { + const settings = this.requireStore().getSettings() + return resolveTuiAgentLaunchEnv('claude', settings.agentDefaultEnv) + } + })({ record, spawnToken }) + claudeProof = this.createClaudeTuiResumeProofWaiter({ + expectedSessionId: providerSessionId, + expectedTranscriptPath, + expectedLaunchToken: spawnToken + }) + } + let launched: RuntimeEnsureAgentSessionResult + try { + launched = await this.ensureAgentSession( + { + kind: 'explicit', + worktree: `id:${record.location.workspaceId}`, + agent, + providerSession: { key: 'session_id', id: providerSessionId }, + presentation: 'background' + }, + {}, + { + spawnToken, + providerHome: record.accountHome.path, + sessionId: record.sessionId, + ...(claudeLaunch ? { launch: claudeLaunch } : {}) + } + ) + } catch (error) { + claudeProof?.dispose() + throw error + } + const terminal = launched.terminal + try { + if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { + throw new Error('The resumed terminal did not publish a process identity.') + } + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) + const proof = + head.handle.provider === 'codex' + ? await this.waitForStructuredTuiProof({ + handle: terminal.handle, + paneKey: terminal.paneKey, + threadId: head.handle.threadId, + spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + : await claudeProof!.prove() + const revealed = await this.focusTerminal(terminal.handle) + return this.refreshStructuredTuiOwnerBinding({ + terminal: { + handle: terminal.handle, + tabId: revealed.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: await readStructuredTuiProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + agent + }), + link: + head.handle.provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }), + ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}) + }) + } catch (error) { + await this.closeTerminal(terminal.handle) + await this.waitForTerminal(terminal.handle, { + condition: 'exit', + timeoutMs: 15_000 + }) + throw error + } finally { + claudeProof?.dispose() + } + }, + waitForTuiExit: async (owner, persistHandle) => { + if (owner.link.handle.provider === 'claude') { + if (!owner.transcriptPath) { + throw new Error('The Claude TUI owner did not retain its transcript path.') + } + const completed = await completeClaudeTuiExit({ + childPid: owner.process.pid, + waitForChildExit: async () => { + await this.waitForStructuredTuiOwnerExit(owner) + return { pid: owner.process.pid, exitCode: null, signal: null } + }, + sessionId: owner.link.handle.sessionId, + transcriptPath: owner.transcriptPath, + fence: owner.link.mintedAtFence, + persistHandle + }) + return { transcriptPath: completed.transcriptPath } + } + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + }, + waitForTuiIdleOrExit: async (owner, signal) => { + return this.waitForStructuredTuiIdleOrExit(owner, signal) + }, + reproveTuiOwner: async ({ record, owner }) => { + const current = this.refreshStructuredTuiOwnerBinding(owner) + const persisted = record.lease.ownerProcess + if ( + !persisted || + persisted.hostId !== current.process.hostId || + persisted.pid !== current.process.pid || + persisted.processStartTimeMs !== current.process.processStartTimeMs || + persisted.spawnToken !== current.process.spawnToken + ) { + throw new Error('The owning terminal does not match the persisted launch identity.') + } + const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The owning agent child process could not be re-proved.') + } + const head = record.providerHandleChain.at(-1) + if ( + !head || + (record.lease.provenHandleLinkId !== null && + current.link.linkId !== record.lease.provenHandleLinkId) || + !agentSessionProviderHandlesEqual(current.link.handle, head.handle) + ) { + throw new Error('agent_session_identity_required') + } + return current + }, + recoverTuiOwner: async (record) => { + const identity = record.lease.ownerProcess + const head = record.providerHandleChain.at(-1) + if (!identity || !head) { + throw new Error('agent_session_identity_required') + } + const candidate = [...this.ptysById.values()].find( + (pty) => + pty.connected && + pty.launchToken === identity.spawnToken && + pty.launchAgent === head.handle.provider && + pty.tabId && + pty.paneKey + ) + const handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null + if (!candidate?.tabId || !candidate.paneKey || !handle) { + throw new Error('The owning agent terminal could not be recovered.') + } + const transcriptPath = + head.handle.provider === 'codex' + ? ( + await this.waitForStructuredTuiProof({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + spawnToken: identity.spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + ).transcriptPath + : await resolveSessionFilePath('claude', head.handle.sessionId, { + claudeProjectsDir: join(record.accountHome.path, 'projects') + }) + if (head.handle.provider === 'claude' && !transcriptPath) { + throw new Error('The owning Claude transcript could not be recovered.') + } + return { + terminal: { + handle, + tabId: candidate.tabId, + paneKey: candidate.paneKey, + ptyId: candidate.ptyId + }, + process: identity, + link: + head.handle.provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }) + : head, + ...(transcriptPath ? { transcriptPath } : {}) + } + }, + stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), + tuiStatus: (owner) => this.structuredTuiStatus(owner), + stopFailedTuiLaunch: async (owner) => { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + const current = this.refreshStructuredTuiOwnerBinding(owner) + try { + await this.closeTerminal(current.terminal.handle) + } catch (error) { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + throw error + } + } + } + await this.waitForStructuredTuiOwnerExit(owner) + } + } + } + + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { + const pty = this.ptysById.get(owner.terminal.ptyId) + if (!pty?.connected) { + throw new Error('The owning agent terminal lost its launch identity.') + } + const handle = this.issueStructuredTuiPtyHandle(pty) + if (handle === owner.terminal.handle) { + return owner + } + return { ...owner, terminal: { ...owner.terminal, handle } } + } + + private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.syntheticTerminalHandles.add(handle) + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private async waitForStructuredTuiPtyExit(ptyId: string): Promise { + while (this.ptysById.get(ptyId)?.connected === true) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { + await waitForStructuredTuiExitProof({ + identity: owner.process, + waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) + }) + } + + private async waitForStructuredTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> { + const deadline = Date.now() + 250 + while (!signal.aborted && Date.now() < deadline) { + if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { + await this.waitForStructuredTuiOwnerExit(owner) + return 'exited' + } + if (this.structuredTuiStatus(owner) === 'idle') { + return 'idle' + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return null + } + + private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { + const identity = record.lease.ownerProcess + if (!identity) { + return + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return + } + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The recovered owner process could not be stopped safely.') + } + try { + process.kill(identity.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('The recovered owner process did not exit.') + } + + private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { + const pty = this.ptysById.get(owner.terminal.ptyId) + const paneKey = pty?.paneKey ?? owner.terminal.paneKey + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) + if (explicit) { + return explicit.status === 'idle' ? 'idle' : 'busy' + } + if (pty?.connected) { + const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + return hasStructuredTuiIdleEvidence({ + blocked: detectTerminalWaitBlockedReason(text) !== null, + status: pty.lastAgentStatus, + statusObservedLive: pty.lastAgentStatusObservedLive + }) + ? 'idle' + : 'busy' + } + return 'busy' + } + + private createClaudeTuiResumeProofWaiter(input: { + expectedSessionId: string + expectedTranscriptPath: string + expectedLaunchToken: string + }) { + let resolveEnvelope!: (envelope: unknown) => void + const envelope = new Promise((resolve) => { + resolveEnvelope = resolve + }) + const dispose = agentHookServer.subscribeClaudeHookEnvelopes((candidate) => { + const evidence = readClaudeTuiSessionStartEvidence(candidate) + if ( + evidence?.launchToken === input.expectedLaunchToken && + evidence.sessionId === input.expectedSessionId + ) { + resolveEnvelope(candidate) + } + }) + return { + prove: () => + proveClaudeTuiResume({ + ...input, + waitForSessionStart: () => envelope + }), + dispose + } + } + + private async waitForStructuredTuiProof(input: { + handle: string + paneKey: string + threadId: string + spawnToken: string + codexHome: string + sessionId: string + }): Promise<{ transcriptPath?: string }> { + const readBoundPty = (): RuntimePtyWorktreeRecord => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.launchAgent !== 'codex' || + pty.launchToken !== input.spawnToken + ) { + throw new Error('The resumed terminal lost its launch identity.') + } + return pty + } + const initialPty = readBoundPty() + const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 + return proveCodexTuiRollout({ + codexHome: input.codexHome, + threadId: input.threadId, + kittyKeyboardFlags, + readOutput: () => { + const pty = readBoundPty() + return { + text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), + lastOutputAt: pty.lastOutputAt + } + }, + write: (data) => { + const pty = readBoundPty() + return ( + this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { + sessionId: input.sessionId, + spawnToken: input.spawnToken + }) ?? false + ) } }) } @@ -13820,6 +14297,7 @@ export class OrcaRuntimeService { this.intentionalHandlelessPtyStops.has(ptyId) && (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) + agentSessionPtyWriteGate.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -17347,11 +17825,14 @@ export class OrcaRuntimeService { return true } - private getFreshExplicitAgentStatusForHandle(handle: string): { + private getFreshExplicitAgentStatusForHandle( + handle: string, + paneKeyOverride?: string | null + ): { status: NonNullable updatedAt: number } | null { - const paneKey = this.getPaneKeyForTerminalHandle(handle) + const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle) const now = Date.now() let bestStatus: NonNullable | null = null let bestUpdatedAt = -1 @@ -25346,7 +25827,8 @@ export class OrcaRuntimeService { async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, - _caller: RuntimeAgentSessionRpcCaller = {} + _caller: RuntimeAgentSessionRpcCaller = {}, + handoffAuthority?: StructuredAgentSessionHandoffAuthority ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -25356,7 +25838,11 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const namespace = + resolvedNamespace && handoffAuthority + ? { ...resolvedNamespace, providerRoot: handoffAuthority.providerHome } + : resolvedNamespace if ( !namespace || !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) @@ -25382,21 +25868,43 @@ export class OrcaRuntimeService { isRemote, terminalWindowsShell: settings.terminalWindowsShell }) - const startup = buildAgentResumeStartupPlan({ - agent: request.agent, - providerSession: identity.providerSession, - cmdOverrides: settings.agentCmdOverrides ?? {}, - agentArgs: - request.agentArgs !== undefined - ? request.agentArgs - : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), - ompResumeFilePath: request.ompResumeFilePath, - sessionOptions: this.toAgentSessionOptions(request.launchPreferences), - platform, - shell, - isRemote - }) + const startupShell = resolveStartupShell(platform, shell) + const startup = handoffAuthority?.launch + ? { + agent: request.agent, + launchCommand: buildShellCommandFromArgv( + [handoffAuthority.launch.command, ...handoffAuthority.launch.args], + startupShell + ), + expectedProcess: TUI_AGENT_CONFIG[request.agent].expectedProcess, + followupPrompt: null, + launchConfig: { + agentCommand: handoffAuthority.launch.command, + agentArgs: handoffAuthority.launch.args + .map((arg) => quoteStartupArg(arg, startupShell)) + .join(' '), + agentEnv: handoffAuthority.launch.env + }, + env: handoffAuthority.launch.env + } + : buildAgentResumeStartupPlan({ + agent: request.agent, + providerSession: identity.providerSession, + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: + request.agentArgs !== undefined + ? request.agentArgs + : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), + agentEnv: { + ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + ...(handoffAuthority ? { CODEX_HOME: handoffAuthority.providerHome } : {}) + }, + ompResumeFilePath: request.ompResumeFilePath, + sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + platform, + shell, + isRemote + }) if (!startup) { throw new Error('agent_session_identity_required') } @@ -25414,6 +25922,7 @@ export class OrcaRuntimeService { } const terminal = await this.createTerminal(`id:${workspace.id}`, { command: startup.launchCommand, + ...(handoffAuthority?.launch ? { cwd: handoffAuthority.launch.cwd } : {}), env: startup.env, launchConfig: startup.launchConfig, launchAgent: request.agent, @@ -25422,6 +25931,12 @@ export class OrcaRuntimeService { leafId: request.placement?.leafId, persistHostSessionBinding: true, agentSessionClaim: claim, + ...(handoffAuthority + ? { + launchToken: handoffAuthority.spawnToken, + structuredAgentSessionId: handoffAuthority.sessionId + } + : {}), signal: _caller.signal }) return { @@ -25925,6 +26440,9 @@ export class OrcaRuntimeService { leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (launchOpts.structuredAgentSessionId) { + agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId) + } const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { if (persistHostSessionBinding) { @@ -26002,6 +26520,7 @@ export class OrcaRuntimeService { title: pty?.title ?? launchOpts.title ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, + ...(result.pid ? { processId: result.pid } : {}), ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index b052242cc97..7e6130a136b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -191,3 +191,14 @@ export const UnsubscribeParams = z subscriptionId: Identifier('Invalid subscription id').optional() }) .strict() + +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry']).optional() + }) + .strict() + +export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 8e7a543d833..37c4697c2a1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -94,6 +94,8 @@ function hostStub(): StructuredAgentSessionHost { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ ok: true, replayed: false })), + handoffStatus: vi.fn(async () => ({ owner: 'tui', phase: 'queued' })), readOptions: vi.fn(async () => ({ models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], current: { model: 'gpt-live' } @@ -189,7 +191,7 @@ describe('capability gating', () => { for (const method of STRUCTURED_AGENT_SESSION_METHODS) { expect(names).toContain(method.name) } - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(12) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(14) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -313,6 +315,22 @@ describe('method routing', () => { 'question' ]) }) + + it('routes a mobile reverse request through the host-owned handoff coordinator', async () => { + const params = { + envelope: envelope(), + direction: 'to-native', + mode: 'after-turn', + action: 'start' + } + const response = await call('agentSession.requestHandoff', params, { + ...STRUCTURED_CLIENT, + clientId: 'mobile-device-a' + }) + + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith({ callerKey: 'mobile-device-a' }, params) + }) }) describe('mobile image provenance', () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 66835b4541d..21beb6afe4a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -12,10 +12,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { - StructuredAgentSessionCaller, - StructuredAgentSessionHost -} from '../../../native-chat/agent-session-wire/structured-agent-session-host' +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 { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' import { AttachParams, @@ -23,6 +21,8 @@ import { CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, + HandoffStatusParams, OptionsParams, RespondParams, SendParams, @@ -209,6 +209,16 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: SetOptionParams, handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) }), + defineMethod({ + name: 'agentSession.requestHandoff', + params: HandoffParams, + handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.handoffStatus', + params: HandoffStatusParams, + handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + }), defineMethod({ name: 'agentSession.options', params: OptionsParams, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 0f087a2c272..1971182cd69 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -190,12 +190,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'agentSession.create', 'agentSession.createSupport', 'agentSession.cancel', + 'agentSession.handoffStatus', 'agentSession.history', 'agentSession.options', 'agentSession.respondToApproval', 'agentSession.respondToQuestion', 'agentSession.send', 'agentSession.setOption', + 'agentSession.requestHandoff', 'agentSession.subscribe', 'agentSession.unsubscribe', 'browser.back', diff --git a/src/main/runtime/structured-agent-session-pty-binding.test.ts b/src/main/runtime/structured-agent-session-pty-binding.test.ts new file mode 100644 index 00000000000..e6656c025a8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pty-binding.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const SESSION_ID = 'structured-session-1' + +afterEach(() => agentSessionPtyWriteGate.detachRecordLookup()) + +describe('structured handoff PTY binding', () => { + it('binds before runtime writes and unbinds on process exit', async () => { + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + } + vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const write = vi.fn(() => true) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })), + write, + kill: () => true, + getForegroundProcess: async () => null + }) + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION_ID, + runtimeKind: 'native', + handoffStage: 'new-owner-proving' + }) + ) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => + sessionId === SESSION_ID ? record : null + ) + + await runtime.createTerminal('id:worktree-1', { + command: 'codex resume thread-1', + structuredAgentSessionId: SESSION_ID + }) + + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID) + await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false) + expect(write).not.toHaveBeenCalled() + + runtime.onPtyExit('pty-structured', 0) + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 37b2612b166..f3eb4a4542b 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -23,9 +23,11 @@ import { } from '../codex/codex-structured-session-adapter' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' /** Sibling of the journal tree rather than inside it: one file adjudicates every * session's lease, while a journal is per session. */ @@ -48,6 +50,7 @@ export type StructuredAgentSessionRuntimeDeps = { openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] onError?: (input: { scope: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport } type InstalledRuntime = { @@ -74,6 +77,7 @@ export async function stopStructuredAgentSessionRuntime(): Promise { const pending = installing installing = null setStructuredAgentSessionHost(null) + agentSessionPtyWriteGate.detachRecordLookup() if (!pending) { return } @@ -93,56 +97,63 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { - const record = store.getRecord(observed.sessionId) - if (!record || record.provider !== 'claude') { - return - } - const fence = record.lease.runtimeFence - await store.recordProviderHandle({ - sessionId: observed.sessionId, - fence, - link: claudeProviderHandleLink({ - sessionId: observed.providerSessionId, - leafUuid: observed.leafUuid, - resumed: record.providerHandleChain.length > 0, + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + try { + const codex = new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}) + }) + const claude = new ClaudeStructuredSessionAdapter({ + resolveLaunch: createClaudeStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + ...(deps.resolveClaudeCommand ? { resolveCommand: deps.resolveClaudeCommand } : {}), + ...(deps.resolveClaudeLaunchEnv ? { resolveEnv: deps.resolveClaudeLaunchEnv } : {}) + }), + ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), + persistHandle: async (observed) => { + const record = store.getRecord(observed.sessionId) + if (!record || record.provider !== 'claude') { + return + } + const fence = record.lease.runtimeFence + await store.recordProviderHandle({ + sessionId: observed.sessionId, fence, - observedAt: Date.now() - }), - now: Date.now() - }) - } - }) - const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => - Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined) - ) - const host = new StructuredAgentSessionHost({ - store, - adapter, - journalRoot: deps.stateDirectory, - claimKeyId: deps.claimKeyId, - probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), - onEventSinkError: ({ sessionId, error }) => - deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }) - }) - setStructuredAgentSessionHost(host) - return { host, adapter } + link: claudeProviderHandleLink({ + sessionId: observed.providerSessionId, + leafUuid: observed.leafUuid, + resumed: record.providerHandleChain.length > 0, + fence, + observedAt: Date.now() + }), + now: Date.now() + }) + } + }) + const adapter = new StructuredAgentSessionAdapterRouter({ claude, codex }, async () => + Promise.all([claude.closeAll(), codex.closeAll()]).then(() => undefined) + ) + const host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: deps.stateDirectory, + claimKeyId: deps.claimKeyId, + probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), + onEventSinkError: ({ sessionId, error }) => + deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + ...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {}) + }) + setStructuredAgentSessionHost(host) + return { host, adapter } + } catch (error) { + agentSessionPtyWriteGate.detachRecordLookup() + throw error + } } /** diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts new file mode 100644 index 00000000000..3652aae1880 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' + +const identity: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 123, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-token' +} + +describe('structured TUI exit proof', () => { + it('accepts the exact terminal exit event without probing', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe }) + ).resolves.toBeUndefined() + expect(probe).not.toHaveBeenCalled() + }) + + it.each([ + { outcome: 'pid-absent' as const }, + { outcome: 'identity-mismatch' as const, field: 'process-start-time' as const } + ])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof + }) + ).resolves.toBeUndefined() + }) + + it.each([ + { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, + { outcome: 'indeterminate' as const, reason: 'probe unavailable' } + ])('fails closed while the recorded process may still own: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof, + staleHandleProbeAttempts: 1 + }) + ).rejects.toThrow('terminal_handle_stale') + }) + + it('retries the persisted process identity when handle retirement wins the exit race', async () => { + const probe = vi + .fn() + .mockResolvedValueOnce({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + }) + .mockResolvedValueOnce({ outcome: 'pid-absent' as const }) + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe, + staleHandleProbeAttempts: 2, + staleHandleProbeIntervalMs: 0 + }) + ).resolves.toBeUndefined() + expect(probe).toHaveBeenNthCalledWith(1, identity) + expect(probe).toHaveBeenNthCalledWith(2, identity) + }) + + it('preserves unrelated terminal wait failures', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('timeout') + }, + probe + }) + ).rejects.toThrow('timeout') + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts new file mode 100644 index 00000000000..c9c733f4ef2 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.ts @@ -0,0 +1,44 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' + +type ExitProofInput = { + identity: AgentSessionProcessIdentity + waitForExit: () => Promise + probe?: (identity: AgentSessionProcessIdentity) => Promise + staleHandleProbeAttempts?: number + staleHandleProbeIntervalMs?: number +} + +const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50 +const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100 + +function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' +} + +async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise { + const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS + const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (provesRecordedProcessExited(await probe(input.identity))) { + return + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + throw staleError +} + +export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { + try { + await input.waitForExit() + } catch (error) { + if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { + throw error + } + await waitForRecordedProcessExit(input, error) + } +} diff --git a/src/main/runtime/structured-tui-idle-evidence.test.ts b/src/main/runtime/structured-tui-idle-evidence.test.ts new file mode 100644 index 00000000000..7177cb9bfc3 --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' + +describe('structured TUI idle evidence', () => { + it('does not treat a ready prompt preview as proof that a turn is idle', () => { + const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace' + expect(readyPreview).toContain('OpenAI Codex') + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false }) + ).toBe(false) + }) + + it('requires an explicit idle state and still rejects blocked prompts', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true }) + ).toBe(true) + expect( + hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true }) + ).toBe(false) + }) + + it('does not authorize a restored idle status before live observation', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-tui-idle-evidence.ts b/src/main/runtime/structured-tui-idle-evidence.ts new file mode 100644 index 00000000000..d91518cdd4c --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.ts @@ -0,0 +1,9 @@ +import type { AgentStatus } from '../../shared/agent-title-core' + +export function hasStructuredTuiIdleEvidence(input: { + blocked: boolean + status: AgentStatus | null + statusObservedLive: boolean +}): boolean { + return !input.blocked && input.status === 'idle' && input.statusObservedLive +} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts new file mode 100644 index 00000000000..59a06833fa6 --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' + +describe('structured TUI process identity', () => { + it('binds the direct Codex child instead of the PTY shell pid', async () => { + const readStartTime = vi.fn(async () => 1_700_000_000_000) + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' }, + { pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' } + ], + readStartTime + }) + ).resolves.toEqual({ + hostId: 'local', + pid: 101, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-1' + }) + expect(readStartTime).toHaveBeenCalledWith(101, 'darwin') + }) + + it('fails closed when sibling Codex children make the owner ambiguous', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'win32', + readWindowsRows: async () => [ + { pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' }, + { pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' }, + { pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' } + ] + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it('fails closed when the process snapshot omitted the PTY root', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' } + ] + }) + ).rejects.toThrow('root process was not present') + }) +}) diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts new file mode 100644 index 00000000000..d9b200e4d42 --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -0,0 +1,97 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + getFreshProcessTableSnapshot, + type ProcessTableRow +} from '../../shared/process-table-snapshot' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows' +import { readProcessStartTimeMs } from './agent-session-process-identity-probe' + +type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean } + +function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { + const children = new Map() + for (const row of rows) { + children.set(row.ppid, [...(children.get(row.ppid) ?? []), row]) + } + const found: (ProcessRow & { depth: number })[] = [] + const pending = [{ pid: rootPid, depth: 0 }] + const seen = new Set() + while (pending.length > 0) { + const current = pending.pop()! + if (seen.has(current.pid)) { + continue + } + seen.add(current.pid) + const row = rows.find((candidate) => candidate.pid === current.pid) + if (row) { + found.push({ ...row, depth: current.depth }) + } + for (const child of children.get(current.pid) ?? []) { + pending.push({ pid: child.pid, depth: current.depth + 1 }) + } + } + return found +} + +export function resolveStructuredTuiChildPid( + rows: ProcessRow[], + rootPid: number, + agent: 'claude' | 'codex' +): number | null { + const candidates = descendants(rows, rootPid).filter( + (row) => recognizeAgentProcessFromCommandLine(row.command)?.agent === agent + ) + const foreground = candidates.filter((row) => row.foreground) + const eligible = foreground.length > 0 ? foreground : candidates + eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid) + if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) { + return null + } + return eligible[0]!.pid +} + +function posixRows(rows: ProcessTableRow[]): ProcessRow[] { + return rows.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: row.stat.includes('+') + })) +} + +export async function readStructuredTuiProcessIdentity(input: { + hostId: string + rootPid: number + spawnToken: string + agent: 'claude' | 'codex' + platform?: NodeJS.Platform + readPosixRows?: () => Promise + readWindowsRows?: typeof queryWindowsProcessRowsFresh + readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise +}): Promise { + const platform = input.platform ?? process.platform + const rows: ProcessRow[] = + platform === 'win32' + ? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: false + })) + : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) + if (!rows.some((row) => row.pid === input.rootPid)) { + throw new Error('The terminal root process was not present in the process snapshot.') + } + const pid = resolveStructuredTuiChildPid(rows, input.rootPid, input.agent) + if (pid === null) { + const agentName = input.agent === 'claude' ? 'Claude' : 'Codex' + throw new Error(`The resumed terminal did not expose one exact ${agentName} child process.`) + } + return { + hostId: input.hostId, + pid, + processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform), + spawnToken: input.spawnToken + } +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 540db7afef8..2a5136b345d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -17,6 +17,7 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useStructuredAgentSession } from './use-structured-agent-session' import { translate } from '@/i18n/i18n' +import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome' function encodeQuestionAnswer(questionId: string, answer: string): string { return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` @@ -99,6 +100,13 @@ export function NativeChatStructuredSession(props: { tabIndex={-1} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > + { + void controller.requestHandoff(direction, mode, action) + }} + />
{viewState.kind === 'loading' ? ( @@ -194,7 +202,9 @@ export function NativeChatStructuredSession(props: { {controller.error ?? composerError}

) : null} - {prompt ? null : ( + {prompt || + controller.handoff?.owner === 'tui' || + controller.handoff?.phase === 'switching' ? null : ( void +} + +function handoffStageCopy(status: AgentSessionHandoffStatus): string { + if (status.stage === 'preparing') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…') + : translate( + 'components.native-chat.handoff.stage.finishingTerminal', + 'Finishing agent terminal…' + ) + } + if (status.stage === 'old-owner-stopped') { + return status.direction === 'to-tui' + ? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…') + : translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…') + } + if (status.stage === 'new-owner-proving') { + return status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.stage.verifyingTerminal', + 'Verifying agent terminal…' + ) + : translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…') + } + if (status.stage === 'recovering') { + return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…') + } + if (status.stage === 'manual-recovery') { + return translate( + 'components.native-chat.handoff.stage.manualRecovery', + 'Agent session needs recovery' + ) + } + return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…') +} + +export function StructuredAgentSessionHandoffChrome({ + status, + isWorking, + onRequest +}: Props): React.JSX.Element | null { + if (!status) { + return null + } + const owner = status?.owner ?? 'native' + const phase = status?.phase ?? 'idle' + const switching = phase === 'switching' || phase === 'waiting-for-exit' + return ( + <> +
+ + {switching + ? translate('components.native-chat.handoff.mode.switching', 'Switching') + : owner === 'tui' + ? translate('components.native-chat.handoff.mode.terminal', 'Terminal') + : translate('components.native-chat.handoff.mode.chat', 'Chat')} + +
+ {phase === 'queued' && status?.direction ? ( + <> + + {status.direction === 'to-tui' + ? translate( + 'components.native-chat.handoff.switchingAfterTurn', + 'Switching after this turn' + ) + : translate( + 'components.native-chat.handoff.returningAfterTurn', + 'Returning after this turn' + )} + + + + ) : owner === 'native' && !switching ? ( + isWorking ? ( + <> + + + + ) : ( + + ) + ) : owner === 'tui' && !switching ? ( + + ) : null} +
+
+ {owner === 'tui' && phase === 'idle' ? ( +
+ + {status?.hostLabel + ? translate( + 'components.native-chat.handoff.agentOpenOnHost', + 'Agent is open in terminal on {{value0}}.', + { value0: status.hostLabel } + ) + : translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')} + + +
+ ) : null} + {switching ? ( +
+ {phase === 'waiting-for-exit' + ? translate( + 'components.native-chat.handoff.exitTerminal', + 'Exit the agent terminal to continue in chat.' + ) + : status?.stage + ? handoffStageCopy(status) + : translate( + 'components.native-chat.handoff.switchingOwner', + 'Switching session owner…' + )} +
+ ) : null} + {phase === 'failed' && status?.error ? ( +
+
+ {status.error.message} + {status.direction && status.error.recoverableOwner !== 'none' ? ( + + ) : null} +
+ {status.error.details ? ( +
+ {translate('components.native-chat.handoff.details', 'Details')} +

{status.error.details}

+
+ ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 070b7ef11ea..db40fbc5b74 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import type { AgentSessionHistoryResult } from '../../../../shared/agent-session-wire' +import type { + AgentSessionHandoffStatus, + AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' import { projectStructuredAgentSessionStatus, structuredAgentSessionPaneKey @@ -19,6 +22,10 @@ import { callStructuredAgentSession, subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + clearStructuredHandoff, + publishStructuredHandoff +} from '@/runtime/structured-agent-session-handoff-store' type StructuredTab = Tab & { contentType: 'agent-session' } @@ -87,6 +94,13 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): target, { sessionId: tab.entityId, ...(state.cursor ? { cursor: state.cursor } : {}) }, (event) => { + if ('handoff' in event && event.handoff) { + publishStructuredHandoff({ + sessionId: tab.entityId, + fence: event.fence ?? state.fence ?? 0, + status: event.handoff + }) + } if ( event.type === 'batch' && !shouldAdvanceStructuredResumeCursor(state.cursor, event.batch.cursor) @@ -127,12 +141,17 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): opening = false } } - void callStructuredAgentSession(target, 'agentSession.history', { - sessionId: tab.entityId, - direction: 'tail', - limit: 40 - }) - .then(async (result) => { + void Promise.all([ + callStructuredAgentSession(target, 'agentSession.history', { + sessionId: tab.entityId, + direction: 'tail', + limit: 40 + }), + callStructuredAgentSession(target, 'agentSession.handoffStatus', { + sessionId: tab.entityId + }).catch(() => null) + ]) + .then(async ([result, handoff]) => { if (stopped) { return } @@ -150,6 +169,14 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): } }) } + if (handoff) { + apply({ type: 'handoff', handoff }) + publishStructuredHandoff({ + sessionId: tab.entityId, + fence: result.ok ? (result.page.fence ?? 0) : (result.fence ?? 0), + status: handoff + }) + } await open() }) .catch(scheduleReconnect) @@ -159,6 +186,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): clearTimeout(reconnectTimer) } unsubscribe() + clearStructuredHandoff(tab.entityId) useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)) } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts index b4fba75d3ee..6b3ec04f8c1 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts @@ -21,7 +21,10 @@ import { recordWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' const LOCAL_OWNER = 'local-structured-session' -export function useStructuredAgentSessionCreate(worktreeId: string): { +export function useStructuredAgentSessionCreate( + worktreeId: string, + agent: 'claude' | 'codex' +): { supported: boolean creating: boolean create: () => Promise @@ -55,14 +58,14 @@ export function useStructuredAgentSessionCreate(worktreeId: string): { const support = await callStructuredAgentSession<{ supported?: boolean }>( target, 'agentSession.createSupport', - { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' } + { worktree: toRuntimeWorktreeSelector(worktreeId), agent } ) if (!stale) { setSupported( showStructuredAgentSessionChoice({ hostCapability, workspaceSupport: support.supported === true, - agent: 'codex' + agent }) ) } @@ -70,7 +73,7 @@ export function useStructuredAgentSessionCreate(worktreeId: string): { return () => { stale = true } - }, [target, worktreeId]) + }, [agent, target, worktreeId]) const create = useCallback(async (): Promise => { if (!supported || creating) { @@ -92,10 +95,14 @@ export function useStructuredAgentSessionCreate(worktreeId: string): { sessionId, clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), expectedRuntimeFence: null, - payloadFingerprint: structuredAgentSessionCreateFingerprint({ sessionId, worktree }) + payloadFingerprint: structuredAgentSessionCreateFingerprint({ + sessionId, + worktree, + agent + }) }, worktree, - agent: 'codex' + agent }) if (!result.ok) { throw new Error(result.refusal.message) @@ -104,7 +111,7 @@ export function useStructuredAgentSessionCreate(worktreeId: string): { } finally { setCreating(false) } - }, [creating, supported, target, worktreeId]) + }, [agent, creating, supported, target, worktreeId]) return { supported, creating, create } } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts index 59f2fcc5001..f775799b646 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useReducer, useRef, useState } from 'react' import type { + AgentSessionHandoffStatus, AgentSessionHistoryResult, AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' @@ -129,11 +130,18 @@ export function useStructuredAgentSessionRead(args: { } } async function refreshTail(): Promise { - const result = await callStructuredAgentSession( - target, - 'agentSession.history', - { sessionId, direction: 'tail', limit: 40 } - ) + const [result, handoff] = await Promise.all([ + callStructuredAgentSession(target, 'agentSession.history', { + sessionId, + direction: 'tail', + limit: 40 + }), + callStructuredAgentSession( + target, + 'agentSession.handoffStatus', + { sessionId } + ).catch(() => null) + ]) if (stopped) { return } @@ -153,6 +161,9 @@ export function useStructuredAgentSessionRead(args: { }) resumeCursorRef.current = result.snapshot.cursor } + if (handoff) { + dispatch({ type: 'handoff', handoff }) + } } const refreshOnFocus = (): void => { if (!document.hasFocus()) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 7c9bb6cf5a5..9a1fc0a8c98 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -3,6 +3,9 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo import type { AgentType } from '../../../../shared/agent-status-types' import type { AgentSessionMutationResult, + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffResult, AgentSessionOptionsResult, AgentSessionPromptResult } from '../../../../shared/agent-session-wire' @@ -61,13 +64,15 @@ export function useStructuredAgentSession(args: { async ( method: string, fingerprintMethod: string, - fields: Record + fields: Record, + operationIdOverride?: string | null ): Promise => { if (stateRef.current.fence === null) { return null } const key = `${fingerprintMethod}:${JSON.stringify(fields)}` - const clientOperationId = operationIds.current.get(key) ?? structuredSessionOperationId() + const clientOperationId = + operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId() operationIds.current.set(key, clientOperationId) const result = await callStructuredAgentSession>( target, @@ -204,6 +209,18 @@ export function useStructuredAgentSession(args: { ), optionSnapshot, optionSurface, - setStructuredOption + setStructuredOption, + requestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action: 'start' | 'cancel-queued' | 'retry' = 'start' + ) => + mutate( + 'agentSession.requestHandoff', + 'agentSession.requestHandoff', + { direction, mode, action }, + action === 'retry' ? stateRef.current.handoff?.operationId : null + ), + handoff: state.handoff } } diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index bb2f1cf02f7..8ea48013abc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -8,7 +8,8 @@ const { storeState, openSettingsPageMock, openSettingsTargetMock, - useDetectedAgentsMock + useDetectedAgentsMock, + useStructuredAgentSessionCreateMock } = vi.hoisted(() => ({ shortcutLabelMock: vi.fn<() => string | null>(), storeState: { @@ -23,7 +24,12 @@ const { }, openSettingsPageMock: vi.fn(), openSettingsTargetMock: vi.fn(), - useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] })) + useDetectedAgentsMock: vi.fn(() => ({ detectedIds: ['claude', 'codex', 'gemini'] })), + useStructuredAgentSessionCreateMock: vi.fn(() => ({ + supported: true, + creating: false, + create: vi.fn(async () => true) + })) })) vi.mock('@/hooks/useDetectedAgents', () => ({ @@ -34,6 +40,10 @@ vi.mock('@/hooks/useShortcutLabel', () => ({ useOptionalShortcutLabel: shortcutLabelMock })) +vi.mock('../native-chat/use-structured-agent-session-create', () => ({ + useStructuredAgentSessionCreate: useStructuredAgentSessionCreateMock +})) + vi.mock('@/store', () => { const useAppStore = Object.assign( (selector: (state: typeof storeState) => unknown) => { @@ -117,6 +127,7 @@ beforeEach(() => { shortcutLabelMock.mockReset() shortcutLabelMock.mockReturnValue(null) useDetectedAgentsMock.mockClear() + useStructuredAgentSessionCreateMock.mockClear() openSettingsPageMock.mockReset() openSettingsTargetMock.mockReset() storeState.settings.defaultTuiAgent = 'codex' @@ -206,6 +217,16 @@ describe('QuickLaunchAgentMenuItems', () => { storeState.settings.defaultTuiAgent = 'blank' expect(renderAgentMenuItems()).not.toContain('data-dropdown-shortcut="true"') }) + + it('offers explicit structured chat creation for Claude and Codex', () => { + const html = renderAgentMenuItems() + + expect(html.match(/Chat session/g) ?? []).toHaveLength(2) + expect(html).toContain('Start Claude without a terminal') + expect(html).toContain('Start Codex without a terminal') + expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'claude') + expect(useStructuredAgentSessionCreateMock).toHaveBeenCalledWith('worktree-1', 'codex') + }) }) describe('shouldShowLaunchWatchdogTimeout', () => { diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 33e0d81cbc3..69df265b9d7 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -117,7 +117,8 @@ function QuickLaunchAgentMenuItemsInner({ const openSettingsPage = useAppStore((s) => s.openSettingsPage) const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const newAgentShortcut = useOptionalShortcutLabel('tab.newAgent') - const structuredSession = useStructuredAgentSessionCreate(worktreeId) + const claudeStructuredSession = useStructuredAgentSessionCreate(worktreeId, 'claude') + const codexStructuredSession = useStructuredAgentSessionCreate(worktreeId, 'codex') const openAgentSettings = useCallback(() => { openSettingsTarget({ pane: 'agents', repoId: null }) @@ -199,6 +200,12 @@ function QuickLaunchAgentMenuItemsInner({ {agents.map((agent) => { const entry = getCatalogEntry(agent) const label = entry?.label ?? agent + const structuredSession = + agent === 'claude' + ? claudeStructuredSession + : agent === 'codex' + ? codexStructuredSession + : null const showsDefaultAgentShortcut = newAgentShortcut !== null && defaultAgent !== 'blank' && agent === defaultAgent return ( @@ -218,7 +225,7 @@ function QuickLaunchAgentMenuItemsInner({ {newAgentShortcut} ) : null} - {agent === 'codex' && structuredSession.supported ? ( + {structuredSession?.supported ? ( { @@ -235,7 +242,8 @@ function QuickLaunchAgentMenuItemsInner({ className="gap-2 rounded-[7px] px-2 py-1.5 pl-6 text-[12px] leading-5 font-medium" title={translate( 'auto.components.tab.bar.QuickLaunchButton.a847779775', - 'Start Codex without a terminal' + 'Start {{value0}} without a terminal', + { value0: label } )} > diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index f05bd81e657..55be0effcf7 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -179,7 +179,12 @@ export default function AiVaultSessionDropLayer({ if (payload.structuredSession) { const { sessionId, workspaceId } = payload.structuredSession if (!activateStructuredAgentSessionById({ worktreeId: workspaceId, sessionId })) { - toast.error('The structured agent session is not available yet. Retry in a moment.') + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) } return true } diff --git a/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx b/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx new file mode 100644 index 00000000000..bba08bd914c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx @@ -0,0 +1,25 @@ +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' + +export function StructuredAgentSessionTerminalReturnButton(props: { + enabled: boolean + onReturn?: () => void +}): React.JSX.Element | null { + if (!props.enabled) { + return null + } + return ( + + ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4498dbe6bef..1c9c2cf1a61 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -243,6 +243,13 @@ import { } from './regular-terminal-focus-ownership' import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts' import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh' +import { useTerminalStructuredHandoff } from '@/runtime/structured-agent-session-handoff-store' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../../shared/structured-agent-session-mutation' type TerminalPaneProps = { tabId: string @@ -340,6 +347,34 @@ function TerminalPane( const isRendererVisible = isVisible && isWorktreeActive const isVisibleRef = useRef(isRendererVisible) isVisibleRef.current = isRendererVisible + const structuredHandoff = useTerminalStructuredHandoff(tabId) + const returnStructuredSession = useCallback(() => { + if (!structuredHandoff) { + return + } + const direction = 'to-native' as const + const mode = 'after-turn' as const + const action = 'start' as const + const sessionId = structuredHandoff.sessionId + const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId) + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }) + const clientOperationId = createStructuredAgentSessionOperationId(() => crypto.randomUUID()) + void callStructuredAgentSession(target, 'agentSession.requestHandoff', { + envelope: { + sessionId, + clientOperationId, + expectedRuntimeFence: structuredHandoff.fence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.requestHandoff', + sessionId, + fields: { direction, mode, action } + }) + }, + direction, + mode, + action + }) + }, [structuredHandoff, worktreeId]) const { nativeChatTranscriptIsLocalReadable, sshReconnectEnvironmentId, @@ -3174,6 +3209,8 @@ function TerminalPane( canToggleNativeChat={activePaneCanToggleChat} isChatViewMode={activePaneIsChatLeaf} onToggleNativeChat={handleToggleNativeChat} + canReturnStructuredSession={structuredHandoff?.status.owner === 'tui'} + onReturnStructuredSession={returnStructuredSession} canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession} onContinueAgentSessionInNewSession={(pane) => contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index a9ed8249179..d78336c17cc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -8,6 +8,7 @@ import { } from 'lucide-react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { Button } from '@/components/ui/button' +import { StructuredAgentSessionTerminalReturnButton } from './StructuredAgentSessionTerminalReturnButton' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag' @@ -50,6 +51,8 @@ type TerminalPaneHeaderOverlayProps = { isChatViewMode?: boolean /** Flip the active pane between the terminal and the native chat view. */ onToggleNativeChat?: () => void + canReturnStructuredSession?: boolean + onReturnStructuredSession?: () => void canContinueAgentSessionInNewSession?: boolean onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void @@ -88,6 +91,8 @@ export default function TerminalPaneHeaderOverlay({ canToggleNativeChat, isChatViewMode, onToggleNativeChat, + canReturnStructuredSession, + onReturnStructuredSession, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onSplitPane, @@ -244,6 +249,10 @@ export default function TerminalPaneHeaderOverlay({ ) : null}
+ {canContinueAgentSessionInNewSession && isActivePane ? ( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2c78eb37ac1..5c613df7aab 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -769,6 +769,9 @@ "additionalErrors": "{{count}} additional images could not be attached." } } + }, + "activateAiVaultStructuredSession": { + "unavailable": "The structured agent session is not available yet. Retry in a moment." } }, "hooks": { @@ -15116,6 +15119,37 @@ "status": { "responding": "Agent is responding" }, + "handoff": { + "mode": { + "switching": "Switching", + "terminal": "Terminal", + "chat": "Chat" + }, + "switchingAfterTurn": "Switching after this turn", + "returningAfterTurn": "Returning after this turn", + "cancel": "Cancel", + "switchAfterTurn": "Switch after this turn", + "stopTurnAndSwitch": "Stop turn and switch", + "openAgentTui": "Open agent TUI", + "returnAfterTurn": "Return after this turn", + "returnToChat": "Return to chat", + "agentOpen": "Agent is open in terminal.", + "agentOpenOnHost": "Agent is open in terminal on {{value0}}.", + "exitTerminal": "Exit the agent terminal to continue in chat.", + "switchingOwner": "Switching session owner…", + "retry": "Retry", + "details": "Details", + "stage": { + "finishingChat": "Finishing chat session…", + "finishingTerminal": "Finishing agent terminal…", + "openingTerminal": "Opening agent terminal…", + "resumingChat": "Resuming chat session…", + "verifyingTerminal": "Verifying agent terminal…", + "verifyingChat": "Verifying chat session…", + "recovering": "Recovering agent session…", + "manualRecovery": "Agent session needs recovery" + } + }, "jumpToLatest": "Jump to latest", "toggle": { "showTerminal": "Show terminal", 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 bffcee51b8b..23a33ce93de 100644 --- a/src/renderer/src/lib/activate-ai-vault-structured-session.ts +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' import { activateAndRevealWorktree } from './worktree-activation' import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation' import { useAppStore } from '@/store' @@ -15,7 +16,12 @@ export function activateAiVaultStructuredSession(session: AiVaultSession): boole sessionId: structured.sessionId }) ) { - toast.error('The structured agent session is not available yet. Retry in a moment.') + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) return true } if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) { diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts new file mode 100644 index 00000000000..0a6e1c58d10 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync' + +describe('local structured session tab projection', () => { + it('keeps only structured tabs without replaying renderer-owned groups', () => { + const snapshot = { + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'agent-session:claude-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'agent-session:claude-1', + tabOrder: ['terminal-1', 'agent-session:claude-1'] + } + ], + tabs: [ + { + type: 'terminal', + id: 'terminal-1', + parentTabId: 'terminal-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:claude-1', + title: 'Claude Chat', + sessionId: 'claude-1', + agent: 'claude', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({ + tabGroups: undefined, + tabs: [expect.objectContaining({ type: 'agent-session', agent: 'claude' })] + }) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 43701c512a7..9ae7a2a67d1 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -11,20 +11,17 @@ type SessionTabsEvent = | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } | { type: 'end' } -function structuredOnly(snapshot: RuntimeMobileSessionTabsResult): RuntimeMobileSessionTabsResult { +export function projectLocalStructuredSessionTabs( + snapshot: RuntimeMobileSessionTabsResult +): RuntimeMobileSessionTabsResult { const structuredIds = new Set( snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) ) return { ...snapshot, tabs: snapshot.tabs.filter((tab) => structuredIds.has(tab.id)), - tabGroups: snapshot.tabGroups?.map((group) => ({ - ...group, - tabOrder: group.tabOrder.filter((id) => structuredIds.has(id)), - activeTabId: - group.activeTabId && structuredIds.has(group.activeTabId) ? group.activeTabId : null, - recentTabIds: group.recentTabIds?.filter((id) => structuredIds.has(id)) - })) + // Why: replaying local terminal group ids under the structured owner clones the workspace layout. + tabGroups: undefined } } @@ -34,7 +31,7 @@ function applySnapshots(snapshots: readonly RuntimeMobileSessionTabsResult[]): v for (const snapshot of snapshots) { const patch = applyWebSessionTabsSnapshot( next, - structuredOnly(snapshot), + projectLocalStructuredSessionTabs(snapshot), LOCAL_STRUCTURED_SESSION_OWNER ) next = patch === next ? next : ({ ...next, ...patch } as typeof state) diff --git a/src/renderer/src/runtime/structured-agent-session-handoff-store.ts b/src/renderer/src/runtime/structured-agent-session-handoff-store.ts new file mode 100644 index 00000000000..8800f349802 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-handoff-store.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from 'react' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' + +export type TerminalStructuredHandoff = { + sessionId: string + fence: number + status: AgentSessionHandoffStatus +} + +const byTerminalTabId = new Map() +const listeners = new Set<() => void>() + +export function publishStructuredHandoff(input: TerminalStructuredHandoff): void { + for (const [tabId, current] of byTerminalTabId) { + if (current.sessionId === input.sessionId && tabId !== input.status.terminal?.tabId) { + byTerminalTabId.delete(tabId) + } + } + if (input.status.terminal) { + byTerminalTabId.set(input.status.terminal.tabId, input) + } + for (const listener of listeners) { + listener() + } +} + +export function clearStructuredHandoff(sessionId: string): void { + let changed = false + for (const [tabId, current] of byTerminalTabId) { + if (current.sessionId === sessionId) { + byTerminalTabId.delete(tabId) + changed = true + } + } + if (changed) { + for (const listener of listeners) { + listener() + } + } +} + +export function useTerminalStructuredHandoff(tabId: string): TerminalStructuredHandoff | null { + return useSyncExternalStore( + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + () => byTerminalTabId.get(tabId) ?? null + ) +} diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts new file mode 100644 index 00000000000..a42a3fb9a90 --- /dev/null +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::/worktree' +const GROUP_ID = 'group-1' + +function structuredTab(sessionId: string, sortOrder: number): Tab { + return { + id: `structured-agent-session-${sessionId}`, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +describe('web session structured tab focus', () => { + it('keeps the exact active structured tab across a host snapshot', () => { + const first = structuredTab('session-1', 0) + const second = structuredTab('session-2', 1) + const state = { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: second.id, + tabOrder: [first.id, second.id] + } + ] + }, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } as WebSessionTabsSyncState + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: GROUP_ID, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: GROUP_ID, + activeTabId: 'agent-session:session-1', + tabOrder: ['agent-session:session-1', 'agent-session:session-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-2', + title: 'Codex Chat', + sessionId: 'session-2', + agent: 'codex', + isActive: false + } + ] + } + + const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10) + + expect(patch.groupsByWorktree?.[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index b7a0915848c..936248dfbea 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -2295,6 +2295,18 @@ function findCurrentVisibleUnifiedTabId(args: { if (!nextUnifiedTabs) { return null } + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const activeGroupTabId = state.groupsByWorktree[worktreeId]?.find( + (group) => group.id === activeGroupId + )?.activeTabId + if ( + activeGroupTabId && + nextUnifiedTabs.some( + (tab) => tab.id === activeGroupTabId && tab.contentType === 'agent-session' + ) + ) { + return activeGroupTabId + } const currentVisibleType = state.activeTabTypeByWorktree[worktreeId] ?? (state.activeWorktreeId === worktreeId ? state.activeTabType : null) diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index c847c473147..9c2e33b00c4 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -137,7 +137,12 @@ export function evaluateAgentSessionAcquisition(args: { // Why: the retry key is operation id + fence + stage; a different id is a different intent. return { decision: 'refused', code: 'agent_session_operation_conflict' } } - if (lease.ownerProcess === null && lease.handoffStage === 'old-owner-stopped') { + if ( + lease.ownerProcess === null && + lease.handoffStage === 'old-owner-stopped' && + lease.claimStatus === 'reserved' && + lease.reservedSpawnToken !== null + ) { // Why: an idempotent re-run of a reservation that already exists at this fence. return { decision: 'retry-reservation', fence: lease.runtimeFence } } diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index 40a3ec5fbf6..aebd2618522 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -129,7 +129,7 @@ export function admitAgentSessionMutation(input: { /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { - if (agentSessionLeaseAdmitsWriter(lease)) { + if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) { return null } if (lease.unreconciled) { @@ -144,6 +144,12 @@ function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireR message: `The session is mid-handoff (${lease.handoffStage}).` } } + if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { + return { + code: 'agent_session_conflict', + message: 'The agent terminal owns this session.' + } + } return { code: 'agent_session_ownership_unknown', message: 'The session has no live owner to accept writes.' diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index f53c6665794..c1f90a9a59c 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -52,6 +52,28 @@ export function agentSessionOperationKey(callerKey: string, operationId: string) return `${callerKey}${OPERATION_KEY_SEPARATOR}${operationId}` } +export function settleAgentSessionOperation( + rows: ReadonlyMap, + args: { + /** Restart reconciliation omits this because the lease persists no client identity. */ + callerKey?: string + operationId: string + outcome: AgentSessionOperationOutcome + } +): Map { + const targetKey = args.callerKey + ? agentSessionOperationKey(args.callerKey, args.operationId) + : null + return new Map( + [...rows].map(([key, row]) => [ + key, + (targetKey ? key === targetKey : row.operationId === args.operationId) + ? { ...row, outcome: args.outcome } + : row + ]) + ) +} + /** * Retention floor. The tombstone must outlive the window in which its id could still be admitted * as new, plus the accepted future skew — otherwise a retry arriving in the gap becomes a second diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index 2f29691e8a4..29f73a103ca 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -12,6 +12,39 @@ import type { AgentJournalSnapshot, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record' + +export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' +export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' +export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' + +export type AgentSessionHandoffStatus = { + owner: AgentSessionOwnerRuntimeKind | 'none' + direction: AgentSessionHandoffDirection | null + phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' + stage: AgentSessionHandoffStage | null + operationId: string | null + hostLabel?: string + terminal?: { + handle: string + tabId: string + paneKey: string + } + error?: { + message: string + details?: string + recoverableOwner: AgentSessionOwnerRuntimeKind | 'none' + } +} + +export type AgentSessionHandoffRequest = { + envelope: AgentSessionMutationEnvelope + direction: AgentSessionHandoffDirection + mode: AgentSessionHandoffMode + action?: AgentSessionHandoffAction +} + +export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ @@ -80,14 +113,28 @@ export type AgentSessionJournalBatch = { } export type AgentSessionSubscribeEvent = - | { type: 'snapshot'; sessionId: string; snapshot: AgentJournalSnapshot; fence: number } - | { type: 'batch'; sessionId: string; batch: AgentSessionJournalBatch } + | { + type: 'snapshot' + sessionId: string + snapshot: AgentJournalSnapshot + fence: number + handoff?: AgentSessionHandoffStatus + } + | { + type: 'batch' + sessionId: string + batch: AgentSessionJournalBatch + /** Added with handoff state so mixed-version cursors retain the ownership fence. */ + fence?: number + handoff?: AgentSessionHandoffStatus + } | { type: 'reset' sessionId: string reset: AgentJournalResetReason snapshot: AgentJournalSnapshot fence: number + handoff?: AgentSessionHandoffStatus } | { type: 'end' } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index dea86b760ca..38b5c203597 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -689,6 +689,8 @@ export type RuntimeTerminalCreate = { agentSessionDisposition?: 'created' | 'adopted' /** The host attached this request to the existing stable pane owner. */ isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number } export type RuntimeTerminalSplit = { diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts index e1af3549097..51bc7fa0537 100644 --- a/src/shared/structured-agent-session-coalescer.ts +++ b/src/shared/structured-agent-session-coalescer.ts @@ -31,7 +31,11 @@ function mergeBatch( items: [...items.values()], removedItemIds: [...new Set([...left.batch.removedItemIds, ...right.batch.removedItemIds])], submissions: [...submissions.values()] - } + }, + ...(right.fence !== undefined || left.fence !== undefined + ? { fence: right.fence ?? left.fence } + : {}), + ...(right.handoff || left.handoff ? { handoff: right.handoff ?? left.handoff } : {}) } } diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts index 2a1abceebeb..8b5c1a5bb0f 100644 --- a/src/shared/structured-agent-session-reducer.ts +++ b/src/shared/structured-agent-session-reducer.ts @@ -4,7 +4,11 @@ import type { AgentJournalSnapshot, AgentJournalSubmission } from './agent-session-journal-types' -import type { AgentSessionHistoryPage, AgentSessionSubscribeEvent } from './agent-session-wire' +import type { + AgentSessionHandoffStatus, + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from './agent-session-wire' export type StructuredAgentSessionState = { epoch: string | null @@ -15,11 +19,13 @@ export type StructuredAgentSessionState = { hasOlder: boolean status: 'idle' | 'loading' | 'ready' | 'error' error?: string + handoff: AgentSessionHandoffStatus | null } export type StructuredAgentSessionAction = | { type: 'loading' } | { type: 'error'; message: string } + | { type: 'handoff'; handoff: AgentSessionHandoffStatus } | { type: 'event'; event: AgentSessionSubscribeEvent } | { type: 'tail-page'; page: AgentSessionHistoryPage } | { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage } @@ -31,12 +37,14 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { items: [], submissions: [], hasOlder: false, - status: 'idle' + status: 'idle', + handoff: null } function replaceSnapshot( snapshot: AgentJournalSnapshot, - fence: number + fence: number, + handoff?: AgentSessionHandoffStatus ): StructuredAgentSessionState { return { epoch: snapshot.cursor.epoch, @@ -45,7 +53,8 @@ function replaceSnapshot( items: [...snapshot.items].sort((left, right) => left.sequence - right.sequence), submissions: snapshot.submissions, hasOlder: snapshot.items.length >= 40, - status: 'ready' + status: 'ready', + handoff: handoff ?? null } } @@ -88,6 +97,9 @@ export function reduceStructuredAgentSession( if (action.type === 'error') { return { ...state, status: 'error', error: action.message } } + if (action.type === 'handoff') { + return { ...state, handoff: action.handoff } + } if (action.type === 'tail-page') { const pageCursor = action.page.liveCursor ?? action.page.window.newest if ( @@ -104,7 +116,8 @@ export function reduceStructuredAgentSession( items: action.page.items, submissions: action.page.submissions, hasOlder: action.page.hasOlder, - status: 'ready' + status: 'ready', + handoff: state.handoff } } if (action.type === 'older-page') { @@ -123,7 +136,7 @@ export function reduceStructuredAgentSession( return state } if (event.type === 'snapshot' || event.type === 'reset') { - return replaceSnapshot(event.snapshot, event.fence) + return replaceSnapshot(event.snapshot, event.fence, event.handoff) } if (state.epoch !== event.batch.cursor.epoch) { return state @@ -134,10 +147,12 @@ export function reduceStructuredAgentSession( return { ...state, cursor: event.batch.cursor, + fence: event.fence ?? state.fence, items: mergeItems(state.items, event.batch.items, event.batch.removedItemIds), submissions: mergeSubmissions(state.submissions, event.batch.submissions), status: 'ready', - error: undefined + error: undefined, + handoff: event.handoff ?? state.handoff } } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 13e486f0b1c..111bd56bc9a 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -51,6 +51,8 @@ const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.requestHandoff', hostMethod: 'requestHandoff' }, + { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus' }, { method: 'agentSession.options', hostMethod: 'readOptions' }, { method: 'agentSession.history', hostMethod: 'history' }, { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, @@ -188,6 +190,10 @@ function paramsFor(method: string): unknown { const fields = { key: 'model', value: 'gpt-5' } return { envelope: envelope({ method, fields, fence }), ...fields } } + case 'agentSession.requestHandoff': { + const fields = { direction: 'to-tui', mode: 'now', action: 'start' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } default: @@ -272,6 +278,8 @@ describe('cross-version structured agent sessions', () => { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), history: vi.fn(() => ({ ok: true, page: { items: [] } })), subscribe: vi.fn(() => () => undefined), @@ -332,7 +340,7 @@ describe('cross-version structured agent sessions', () => { it('finds no structured method registered on the old build', () => { expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( - 12 + STRUCTURED_CALLS.length + 1 ) })