From 3ec1a7b789fd2b6cbcaefef22057df8066581181 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:11:36 -0700 Subject: [PATCH] Revert "refactor(native-chat): split TUI toggle from structured core" This reverts commit 87759c446316170d432b79d4db79895fd34997b2. --- .../app/h/[hostId]/session/[worktreeId].tsx | 16 ++ .../MobileStructuredAgentSessionView.test.tsx | 104 ++++++++ .../MobileStructuredAgentSessionView.tsx | 149 ++++++++++- ...le-structured-agent-session-view-styles.ts | 28 +- .../mobile-structured-tui-send.test.ts | 131 ++++++++++ .../src/session/mobile-structured-tui-send.ts | 153 +++++++++++ .../use-mobile-structured-agent-session.ts | 19 +- .../use-mobile-structured-session-entry.ts | 3 +- ...use-mobile-structured-session-mutations.ts | 25 +- ...e-mobile-structured-session-writes.test.ts | 50 ++++ .../use-mobile-structured-session-writes.ts | 2 + .../methods/structured-agent-session.test.ts | 33 ++- .../rpc/methods/structured-agent-session.ts | 6 + src/main/runtime/runtime-rpc.ts | 2 + .../NativeChatStructuredSession.tsx | 13 +- ...ructuredAgentSessionHandoffChrome.test.tsx | 82 ++++++ .../StructuredAgentSessionHandoffChrome.tsx | 243 ++++++++++++++++++ .../StructuredAgentSessionStatusBridge.tsx | 42 ++- .../use-structured-agent-session-read.ts | 21 +- .../use-structured-agent-session.test.tsx | 59 +++++ .../use-structured-agent-session.ts | 23 +- ...cturedAgentSessionTerminalReturnButton.tsx | 25 ++ .../components/terminal-pane/TerminalPane.tsx | 37 +++ .../TerminalPaneHeaderOverlay.tsx | 9 + .../structured-agent-session-handoff-store.ts | 50 ++++ ...ss-version-agent-session-wire.unit.test.ts | 5 + 26 files changed, 1298 insertions(+), 32 deletions(-) create mode 100644 mobile/src/session/mobile-structured-tui-send.test.ts create mode 100644 mobile/src/session/mobile-structured-tui-send.ts create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.test.tsx create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx create mode 100644 src/renderer/src/components/terminal-pane/StructuredAgentSessionTerminalReturnButton.tsx create mode 100644 src/renderer/src/runtime/structured-agent-session-handoff-store.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 7cf1367a993..6dcfe9f4af1 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 { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create' import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet' @@ -4675,6 +4676,19 @@ export default function SessionScreen() { } return accepted }} + onTuiSend={(text, restored) => + mobileStructuredTuiSend.sendMobileStructuredTuiComposerMessage({ + client, + connected: connState === 'connected', + agent: activeStructuredTab.agent, + handoff: structuredSessionEntry.session.handoff, + deviceToken: deviceTokenRef.current, + text, + attachments: [...restored, ...structuredSessionEntry.attachments.attachments], + onAccepted: structuredSessionEntry.attachments.clear, + onToast: showToast + }) + } onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit} onRetry={structuredSessionEntry.writes.retry} onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt} @@ -4684,6 +4698,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 c03a2e3ed96..937e6af4bb7 100644 --- a/mobile/src/session/MobileStructuredAgentSessionView.test.tsx +++ b/mobile/src/session/MobileStructuredAgentSessionView.test.tsx @@ -50,6 +50,7 @@ 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( @@ -63,6 +64,7 @@ describe('MobileStructuredAgentSessionView command seam', () => { onLoadOlder: async () => false, outbox: [], onSend, + onTuiSend, onTakeQueuedForEdit: async () => null, onRetry: async () => {}, onRespondToPrompt: async () => true, @@ -72,6 +74,8 @@ describe('MobileStructuredAgentSessionView command seam', () => { onAttachImage: () => {}, onRemoveAttachment: () => {}, onCancel: async () => true, + handoff: null, + onRequestHandoff: async () => true, ...overrides } } @@ -85,6 +89,7 @@ describe('MobileStructuredAgentSessionView command seam', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true onSend.mockClear() + onTuiSend.mockClear() controller = options() render() }) @@ -109,6 +114,27 @@ describe('MobileStructuredAgentSessionView command seam', () => { expect(onSend).not.toHaveBeenCalled() }) + it('disables zero-turn TUI resume with truthful guidance', () => { + render({ + handoff: { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + } + }) + + const label = renderer!.root + .findAllByType('Text') + .find((node) => node.children.includes('Open agent TUI')) + expect(label?.parent?.props).toMatchObject({ + disabled: true, + accessibilityState: { disabled: true }, + accessibilityHint: 'Send a message first to open the agent TUI.' + }) + }) + it('renders an explicit refusal for unsupported advertised commands', async () => { await submit('/review') @@ -118,9 +144,87 @@ describe('MobileStructuredAgentSessionView command seam', () => { ).toEqual(['/review is not available in chat sessions.']) }) + 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 620e47bebc0..deb4ee2eadf 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' @@ -28,6 +33,7 @@ import { restoreMobileStructuredAttachments, type MobileStructuredTimelineRow } from './mobile-structured-session-timeline' +import { hasPersistedStructuredAgentSessionTurn } from '../../../src/shared/structured-agent-session-projection' import { admitStructuredOlderPage, beginStructuredUserScroll, @@ -54,6 +60,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 @@ -63,6 +70,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' | 'recover' + ) => Promise } export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element { @@ -75,7 +88,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen const [commandError, setCommandError] = useState(null) const turnId = activeMobileStructuredTurnId(props.items) const allAttachments = [...restored, ...props.attachments] - const composerEnabled = props.status === 'ready' + 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] @@ -104,6 +126,12 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen return ( + ) } + +function MobileStructuredHandoffBanner(props: { + status: AgentSessionHandoffStatus | null + isWorking: boolean + hasPersistedTurn: 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, + !props.hasPersistedTurn && styles.disabled, + pressed && props.hasPersistedTurn && styles.pressed + ]} + disabled={!props.hasPersistedTurn} + accessibilityRole="button" + accessibilityState={{ disabled: !props.hasPersistedTurn }} + accessibilityHint={ + props.hasPersistedTurn ? undefined : 'Send a message first to open the agent TUI.' + } + // A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle. + onPress={() => void props.onRequest('to-tui', 'after-turn')} + > + 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?.canRetryProof ? ( + void props.onRequest(status.direction!, 'now', 'recover')}> + Retry proof + + ) : 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-view-styles.ts b/mobile/src/session/mobile-structured-agent-session-view-styles.ts index 4e4f0022e9f..131e1040be0 100644 --- a/mobile/src/session/mobile-structured-agent-session-view-styles.ts +++ b/mobile/src/session/mobile-structured-agent-session-view-styles.ts @@ -36,10 +36,36 @@ export const styles = StyleSheet.create({ }, cancelText: { color: colors.statusRed, fontSize: typography.metaSize, fontWeight: '600' }, pressed: { opacity: 0.7 }, + disabled: { opacity: 0.45 }, writeError: { alignItems: 'center', 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..5c7dc5fe262 --- /dev/null +++ b/mobile/src/session/mobile-structured-tui-send.test.ts @@ -0,0 +1,131 @@ +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 { + sendMobileStructuredTuiComposerMessage, + 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('routes stable TUI composer text through terminal.send', async () => { + const client = acceptingClient() + + await expect( + sendMobileStructuredTuiMessage({ + client, + terminal: 'term-tui', + agent: 'codex', + 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', + agent: 'codex', + 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) + }) + + it('types Claude-only slash commands through the provider command path', async () => { + vi.useFakeTimers() + const client = acceptingClient() + + const sending = sendMobileStructuredTuiMessage({ + client, + terminal: 'term-claude', + agent: 'claude', + deviceToken: null, + text: '/clear', + 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', '/', 'c', 'l', 'e', 'a', 'r', '\r']) + }) + + it('settles the composer only after its proven TUI owner accepts the send', async () => { + const onAccepted = vi.fn() + + await expect( + sendMobileStructuredTuiComposerMessage({ + client: acceptingClient(), + connected: true, + agent: 'claude', + handoff: { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: { handle: 'term-claude', tabId: 'tab-claude', paneKey: 'pane-claude' } + }, + deviceToken: null, + text: 'hello', + attachments: [], + onAccepted, + onToast: vi.fn() + }) + ).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledOnce() + }) +}) 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..9adc14093f4 --- /dev/null +++ b/mobile/src/session/mobile-structured-tui-send.ts @@ -0,0 +1,153 @@ +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 + agent: 'claude' | 'codex' + 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(args.agent, 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) + } +} + +export async function sendMobileStructuredTuiComposerMessage(args: { + client: RpcClient | null + connected: boolean + agent: 'claude' | 'codex' + handoff: AgentSessionHandoffStatus | null + deviceToken: string | null + text: string + attachments: readonly PendingNativeChatImage[] + onAccepted: () => void + onToast: (message: string, durationMs: number) => void +}): Promise { + const terminal = getMobileStructuredTuiTerminal(args.handoff) + if (!args.client || !args.connected || !terminal) { + args.onToast('Message not sent (disconnected)', 1800) + return false + } + const outcome = await sendMobileStructuredTuiMessage({ + client: args.client, + terminal, + agent: args.agent, + deviceToken: args.deviceToken, + text: args.text, + attachments: args.attachments + }) + if (outcome === 'rejected') { + args.onToast('Message not sent', 1800) + return false + } + args.onAccepted() + if (outcome === 'unknown') { + args.onToast('Delivery unconfirmed — check chat before retrying', 2400) + } + return true +} 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 5e641e7468a..78ad18ffe2d 100644 --- a/mobile/src/session/use-mobile-structured-session-entry.ts +++ b/mobile/src/session/use-mobile-structured-session-entry.ts @@ -49,7 +49,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 0a0c4a470c0..6a7c4c45347 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, AgentSessionOptionResult, AgentSessionPromptResult @@ -27,12 +29,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' | 'recover' + ) => 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()) @@ -134,9 +142,22 @@ export function useMobileStructuredSessionMutations(args: { 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' | 'recover' = '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.test.ts b/mobile/src/session/use-mobile-structured-session-writes.test.ts index be74d1144c3..8d4cd4f862a 100644 --- a/mobile/src/session/use-mobile-structured-session-writes.test.ts +++ b/mobile/src/session/use-mobile-structured-session-writes.test.ts @@ -652,6 +652,56 @@ describe('useMobileStructuredSessionWrites', () => { expect(crypto.randomUUID).toHaveBeenCalledTimes(1) }) + it('mints a fresh handoff operation after a settled refusal', async () => { + sendRequest + .mockResolvedValueOnce({ + id: 'handoff-stale', + ok: true, + _meta: { runtimeId: 'runtime-1' }, + result: { + ok: false, + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'runtime fence advanced', + currentFence: 4 + } + } + }) + .mockResolvedValueOnce({ + id: 'handoff-started', + ok: true, + _meta: { runtimeId: 'runtime-1' }, + result: { + ok: true, + replayed: false, + fence: 3, + cursor: { epoch: 'epoch-1', sequence: 2 }, + value: { + status: { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'second-operation' + } + } + } + }) + + await act(async () => { + expect(await api!.requestHandoff('to-tui', 'now')).toBe(false) + expect(await api!.requestHandoff('to-tui', 'now')).toBe(true) + }) + + const operationIds = sendRequest.mock.calls.map( + ([, params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + expect(operationIds[0]).toMatch(/-00000000000040008000000000000001$/) + expect(operationIds[1]).toMatch(/-00000000000040008000000000000002$/) + expect(operationIds[1]).not.toBe(operationIds[0]) + }) + it('isolates mutation ids and late errors across session changes', async () => { const first = deferred() sendRequest.mockImplementationOnce(() => first.promise).mockResolvedValueOnce(accepted('b')) diff --git a/mobile/src/session/use-mobile-structured-session-writes.ts b/mobile/src/session/use-mobile-structured-session-writes.ts index ea0ed059dfd..344460e56a4 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([]) @@ -49,6 +50,7 @@ export function useMobileStructuredSessionWrites(args: { client, sessionId, fence, + handoffOperationId: args.handoffOperationId, onRefusal: setError }) const outboxMutations = useMobileStructuredOutboxMutations({ 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 69bfc057d7a..a60f0ca5da5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -191,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(13) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(14) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -316,16 +316,35 @@ describe('method routing', () => { ]) }) - it('does not register the structured handoff mutation', async () => { - const response = await call('agentSession.requestHandoff', { + 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) + }) + + it('accepts manual proof recovery at the RPC boundary', async () => { + const params = { envelope: envelope(), direction: 'to-tui', mode: 'now', - action: 'start' - }) + action: 'recover' + } - expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) - expect(hostCalls.requestHandoff).not.toHaveBeenCalled() + await expect(call('agentSession.requestHandoff', params)).resolves.toMatchObject({ ok: true }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith( + { callerKey: 'trusted-local:runtime' }, + params + ) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index cde0156c062..1225eeb7ea2 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -21,6 +21,7 @@ import { CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, HandoffStatusParams, OptionsParams, RespondParams, @@ -208,6 +209,11 @@ 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, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 860e1db7e9f..008a6f54a51 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/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index cd22005b524..d1fc80396fb 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)}` @@ -115,6 +116,14 @@ 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' ? ( @@ -210,7 +219,9 @@ export function NativeChatStructuredSession(props: { {controller.error ?? composerError}

) : null} - {prompt ? null : ( + {prompt || + controller.handoff?.owner === 'tui' || + controller.handoff?.phase === 'switching' ? null : ( { + it('uses queued-safe admission when the native view still appears idle', () => { + const onRequest = vi.fn() + render( + , + { wrapper: TooltipProvider } + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' })) + + expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn') + }) + + it('disables zero-turn TUI resume with truthful guidance', async () => { + render( + , + { wrapper: TooltipProvider } + ) + + const button = screen.getByRole('button', { name: 'Open agent TUI' }) + expect(button.hasAttribute('disabled')).toBe(true) + fireEvent.pointerMove(button.parentElement!) + expect( + await screen.findAllByText('Send a message first to open the agent TUI.') + ).not.toHaveLength(0) + }) + + it('offers one Retry action for a recoverable dead TUI owner', () => { + const onRequest = vi.fn() + render( + , + { wrapper: TooltipProvider } + ) + + expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry') + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx new file mode 100644 index 00000000000..23c4f1e1d9d --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionHandoffChrome.tsx @@ -0,0 +1,243 @@ +import type { + AgentSessionHandoffDirection, + AgentSessionHandoffMode, + AgentSessionHandoffStatus +} from '../../../../shared/agent-session-wire' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' + +type Props = { + status: AgentSessionHandoffStatus | null + isWorking: boolean + hasPersistedTurn: boolean + onRequest: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action?: 'start' | 'cancel-queued' | 'retry' | 'recover' + ) => 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, + hasPersistedTurn, + 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' && phase === 'idle' ? ( + isWorking ? ( + <> + + + + ) : ( + + + + + + + {!hasPersistedTurn ? ( + + {translate( + 'components.native-chat.handoff.sendFirst', + 'Send a message first to open the agent TUI.' + )} + + ) : null} + + ) + ) : owner === 'tui' && phase === 'idle' ? ( + + ) : 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.canRetryProof ? ( + + ) : 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 5cd15a70023..90e71ca32e0 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-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.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index 3090fc638c7..04fa10f32ff 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -259,6 +259,65 @@ describe('useStructuredAgentSession options', () => { expect(mocks.operationId).toHaveBeenCalledTimes(1) }) + it('mints a fresh handoff operation after a settled refusal', async () => { + let attempts = 0 + mocks.call.mockImplementation((_target, method) => { + if (method === 'agentSession.options') { + return Promise.resolve(OPTIONS) + } + attempts += 1 + return Promise.resolve( + attempts === 1 + ? { + ok: false, + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'runtime fence advanced', + currentFence: 4 + } + } + : { + ok: true, + replayed: false, + value: { + status: { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'operation-2' + } + } + } + ) + }) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex' + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.requestHandoff('to-tui', 'now')).toBeNull() + expect(await result.current.requestHandoff('to-tui', 'now')).toMatchObject({ + status: { phase: 'switching' } + }) + }) + + const mutations = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.requestHandoff' + ) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + ).toEqual(['operation-1', 'operation-2']) + }) + it('ignores an option failure from a superseded fence', async () => { let reject!: (error: Error) => void const pending = new Promise((_resolve, rejectPromise) => { 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 94301dc0489..8ce30089e86 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, AgentSessionOptionResult, AgentSessionOptionsResult, AgentSessionPromptResult @@ -18,7 +21,10 @@ import { createStructuredAgentSessionOptionState, structuredAgentSessionOptionSnapshot } from '../../../../shared/structured-agent-session-options' -import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' +import { + activeStructuredAgentSessionTurnId, + hasPersistedStructuredAgentSessionTurn +} from '../../../../shared/structured-agent-session-projection' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { @@ -223,6 +229,7 @@ export function useStructuredAgentSession(args: { send: outboxController.send, retry: outboxController.retry, isWorking: turnId !== null, + hasPersistedTurn: hasPersistedStructuredAgentSessionTurn(state.items), turnId, cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), respond: (item: StructuredPromptItem, optionId: string) => @@ -235,6 +242,18 @@ export function useStructuredAgentSession(args: { ), optionSnapshot, optionSurface, - setStructuredOption + setStructuredOption, + requestHandoff: ( + direction: AgentSessionHandoffDirection, + mode: AgentSessionHandoffMode, + action: 'start' | 'cancel-queued' | 'retry' | 'recover' = '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/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 8861b076de9..a1f418c9326 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -246,6 +246,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 @@ -343,6 +350,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, @@ -3184,6 +3219,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/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/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 47b981707c0..e41c2f0364a 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 @@ -46,6 +46,7 @@ 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' }, @@ -152,6 +153,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: