Revert "refactor(native-chat): split TUI toggle from structured core"

This reverts commit 87759c4463.
This commit is contained in:
Brennan Benson
2026-08-14 16:08:31 -07:00
parent ed38880bc2
commit 3ec1a7b789
26 changed files with 1298 additions and 32 deletions
@@ -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 ? (
<View style={styles.emptyState}>
@@ -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()
})
})
@@ -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<boolean>
onTuiSend?: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
onTakeQueuedForEdit: (clientMessageId: string) => Promise<MobileStructuredOutboxEntry | null>
onRetry: (clientMessageId: string) => Promise<void>
onRespondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
@@ -63,6 +70,12 @@ type Props = {
onAttachImage: () => void
onRemoveAttachment: (id: string) => void
onCancel: (turnId: string) => Promise<boolean>
handoff: AgentSessionHandoffStatus | null
onRequestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
) => Promise<boolean>
}
export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element {
@@ -75,7 +88,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
const [commandError, setCommandError] = useState<string | null>(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 (
<View style={styles.root}>
<MobileStructuredHandoffBanner
status={props.handoff}
isWorking={turnId !== null}
hasPersistedTurn={hasPersistedStructuredAgentSessionTurn(props.items)}
onRequest={props.onRequestHandoff}
/>
<FlatList
ref={listRef}
inverted
@@ -215,6 +243,15 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
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)
if (command.handled) {
setCommandError(command.error)
@@ -245,8 +282,116 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
}}
isAttaching={props.isAttaching}
disabled={!composerEnabled}
placeholder={props.status === 'ready' ? 'Message Codex' : 'Reconnecting…'}
placeholder={
props.handoff?.owner === 'tui' && !stableTuiOwner
? 'Agent is open in terminal'
: props.status === 'ready'
? 'Message Codex'
: 'Reconnecting…'
}
/>
</View>
)
}
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 (
<View style={styles.handoffBar}>
{props.isWorking ? (
<>
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
onPress={() => void props.onRequest('to-tui', 'after-turn')}
>
<Text style={styles.handoffButtonText}>Switch after this turn</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
onPress={() => void props.onRequest('to-tui', 'stop-turn')}
>
<Text style={styles.handoffButtonText}>Stop turn and switch</Text>
</Pressable>
</>
) : (
<Pressable
style={({ pressed }) => [
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')}
>
<Text style={styles.handoffButtonText}>Open agent TUI</Text>
</Pressable>
)}
</View>
)
}
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 (
<View style={[styles.handoffBanner, status.error ? styles.handoffError : null]}>
<Text style={status.error ? styles.handoffErrorText : styles.handoffText}>{copy}</Text>
{status.phase === 'queued' && status.direction ? (
<Pressable
onPress={() => void props.onRequest(status.direction!, 'after-turn', 'cancel-queued')}
>
<Text style={styles.handoffButtonText}>Cancel</Text>
</Pressable>
) : status.owner === 'tui' && status.phase === 'idle' ? (
<Pressable onPress={() => void props.onRequest('to-native', 'after-turn')}>
<Text style={styles.handoffButtonText}>Return to chat</Text>
</Pressable>
) : status.phase === 'failed' && status.direction && status.error?.canRetryProof ? (
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'recover')}>
<Text style={styles.handoffButtonText}>Retry proof</Text>
</Pressable>
) : status.phase === 'failed' &&
status.direction &&
status.error?.recoverableOwner !== 'none' ? (
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'retry')}>
<Text style={styles.handoffButtonText}>Retry</Text>
</Pressable>
) : null}
</View>
)
}
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…'
}
}
@@ -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' }
})
@@ -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()
})
})
@@ -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<void>
}
const wait = (ms: number): Promise<void> =>
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<MobileNativeChatSendOutcome> {
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<boolean> {
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
}
@@ -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<boolean>
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
}
}
@@ -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,
@@ -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<boolean>
setOption: (key: string, value: string) => Promise<AgentSessionOptionResult | null>
cancel: (turnId: string) => Promise<boolean>
requestHandoff: (
direction: AgentSessionHandoffDirection,
mode: AgentSessionHandoffMode,
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
) => Promise<boolean>
}
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<string, string>())
@@ -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]
)
}
@@ -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<RpcSuccess>()
sendRequest.mockImplementationOnce(() => first.promise).mockResolvedValueOnce(accepted('b'))
@@ -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<MobileStructuredOutboxEntry[]>([])
@@ -49,6 +50,7 @@ export function useMobileStructuredSessionWrites(args: {
client,
sessionId,
fence,
handoffOperationId: args.handoffOperationId,
onRefusal: setError
})
const outboxMutations = useMobileStructuredOutboxMutations({
@@ -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
)
})
})
@@ -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,
+2
View File
@@ -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',
@@ -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"
>
<StructuredAgentSessionHandoffChrome
status={controller.handoff}
isWorking={controller.isWorking}
hasPersistedTurn={controller.hasPersistedTurn}
onRequest={(direction, mode, action) => {
void controller.requestHandoff(direction, mode, action)
}}
/>
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
<NativeChatEmptyState kind="loading" />
@@ -210,7 +219,9 @@ export function NativeChatStructuredSession(props: {
{controller.error ?? composerError}
</p>
) : null}
{prompt ? null : (
{prompt ||
controller.handoff?.owner === 'tui' ||
controller.handoff?.phase === 'switching' ? null : (
<NativeChatComposer
terminalTabId={props.tabId}
paneKey={paneKey}
@@ -0,0 +1,82 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire'
import { TooltipProvider } from '@/components/ui/tooltip'
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
const IDLE_NATIVE: AgentSessionHandoffStatus = {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
afterEach(cleanup)
describe('StructuredAgentSessionHandoffChrome', () => {
it('uses queued-safe admission when the native view still appears idle', () => {
const onRequest = vi.fn()
render(
<StructuredAgentSessionHandoffChrome
status={IDLE_NATIVE}
isWorking={false}
hasPersistedTurn
onRequest={onRequest}
/>,
{ 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(
<StructuredAgentSessionHandoffChrome
status={IDLE_NATIVE}
isWorking={false}
hasPersistedTurn={false}
onRequest={vi.fn()}
/>,
{ 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(
<StructuredAgentSessionHandoffChrome
status={{
owner: 'tui',
direction: 'to-native',
phase: 'failed',
stage: 'old-owner-stopped',
operationId: '1800000000000-00000000000000000000000000000001',
error: {
message: "Couldn't resume chat — the agent terminal still owns this session",
recoverableOwner: 'tui'
}
}}
isWorking={false}
hasPersistedTurn
onRequest={onRequest}
/>,
{ 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')
})
})
@@ -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 (
<>
<div className="flex min-h-9 items-center gap-2 border-b border-border px-3 py-1.5">
<Badge variant="outline">
{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')}
</Badge>
<div className="ml-auto flex items-center gap-1.5">
{phase === 'queued' && status?.direction ? (
<>
<span className="text-xs text-muted-foreground">
{status.direction === 'to-tui'
? translate(
'components.native-chat.handoff.switchingAfterTurn',
'Switching after this turn'
)
: translate(
'components.native-chat.handoff.returningAfterTurn',
'Returning after this turn'
)}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest(status.direction!, 'after-turn', 'cancel-queued')}
>
{translate('components.native-chat.handoff.cancel', 'Cancel')}
</Button>
</>
) : owner === 'native' && phase === 'idle' ? (
isWorking ? (
<>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest('to-tui', 'after-turn')}
>
{translate(
'components.native-chat.handoff.switchAfterTurn',
'Switch after this turn'
)}
</Button>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => onRequest('to-tui', 'stop-turn')}
>
{translate(
'components.native-chat.handoff.stopTurnAndSwitch',
'Stop turn and switch'
)}
</Button>
</>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
type="button"
variant="ghost"
size="xs"
disabled={!hasPersistedTurn}
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
onClick={() => onRequest('to-tui', 'after-turn')}
>
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
</Button>
</span>
</TooltipTrigger>
{!hasPersistedTurn ? (
<TooltipContent side="bottom" sideOffset={4}>
{translate(
'components.native-chat.handoff.sendFirst',
'Send a message first to open the agent TUI.'
)}
</TooltipContent>
) : null}
</Tooltip>
)
) : owner === 'tui' && phase === 'idle' ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest('to-native', 'after-turn')}
>
{isWorking
? translate(
'components.native-chat.handoff.returnAfterTurn',
'Return after this turn'
)
: translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
</Button>
) : null}
</div>
</div>
{owner === 'tui' && phase === 'idle' ? (
<div className="flex items-center justify-between gap-3 border-b border-border bg-muted px-3 py-2 text-xs">
<span>
{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.')}
</span>
<Button
type="button"
variant="link"
size="xs"
onClick={() => onRequest('to-native', 'after-turn')}
>
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
</Button>
</div>
) : null}
{switching ? (
<div className="border-b border-border bg-muted px-3 py-3 text-center text-sm text-muted-foreground">
{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…'
)}
</div>
) : null}
{phase === 'failed' && status?.error ? (
<div
className="border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
role="alert"
>
<div className="flex items-center justify-between gap-3">
<span>{status.error.message}</span>
{status.direction && status.error.canRetryProof ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest(status.direction!, 'now', 'recover')}
>
{translate('components.native-chat.handoff.retryProof', 'Retry proof')}
</Button>
) : status.direction && status.error.recoverableOwner !== 'none' ? (
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => onRequest(status.direction!, 'now', 'retry')}
>
{translate('components.native-chat.handoff.retry', 'Retry')}
</Button>
) : null}
</div>
{status.error.details ? (
<details className="mt-1">
<summary>{translate('components.native-chat.handoff.details', 'Details')}</summary>
<p className="mt-1 text-muted-foreground">{status.error.details}</p>
</details>
) : null}
</div>
) : null}
</>
)
}
@@ -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<AgentSessionHistoryResult>(target, 'agentSession.history', {
sessionId: tab.entityId,
direction: 'tail',
limit: 40
})
.then(async (result) => {
void Promise.all([
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
sessionId: tab.entityId,
direction: 'tail',
limit: 40
}),
callStructuredAgentSession<AgentSessionHandoffStatus>(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))
}
}
@@ -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<void> {
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
target,
'agentSession.history',
{ sessionId, direction: 'tail', limit: 40 }
)
const [result, handoff] = await Promise.all([
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
sessionId,
direction: 'tail',
limit: 40
}),
callStructuredAgentSession<AgentSessionHandoffStatus>(
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()) {
@@ -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<never>((_resolve, rejectPromise) => {
@@ -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<AgentSessionHandoffResult>(
'agentSession.requestHandoff',
'agentSession.requestHandoff',
{ direction, mode, action },
action === 'retry' ? stateRef.current.handoff?.operationId : null
),
handoff: state.handoff
}
}
@@ -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 (
<Button
type="button"
variant="ghost"
size="xs"
className="pane-title-split-trigger"
onClick={(event) => {
event.stopPropagation()
props.onReturn?.()
}}
>
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
</Button>
)
}
@@ -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)
@@ -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({
</button>
) : null}
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0">
<StructuredAgentSessionTerminalReturnButton
enabled={Boolean(canReturnStructuredSession && isActivePane)}
onReturn={onReturnStructuredSession}
/>
{canContinueAgentSessionInNewSession && isActivePane ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -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<string, TerminalStructuredHandoff>()
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
)
}
@@ -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: