diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a34c8643ab0..560bca05166 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -2936,6 +2936,9 @@ export default function SessionScreen() { void readFileTab(tab) return } + if (tab.type === 'agent-session') { + return + } const cached = markdownDocs.get(tab.id) if (cached?.status === 'ready' && cached.isDirty) { return @@ -3134,6 +3137,8 @@ export default function SessionScreen() { setMarkdownActionTarget(tab) } else if (tab.type === 'file') { setFileActionTarget(tab) + } else if (tab.type === 'agent-session') { + return } else { setBrowserActionTarget(tab) } @@ -4610,7 +4615,7 @@ export default function SessionScreen() { ) : showEmptyState ? ( No tabs in this session - {createError ? {createError} : null} + {createError ? {createError} : null} uuid: 11.1.1 patchedDependencies: - react-native-webview@13.16.2: - hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 - path: patches/react-native-webview@13.16.2.patch - react-native@0.83.10: - hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d - path: patches/react-native@0.83.10.patch + react-native-webview@13.16.2: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 + react-native@0.83.10: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d importers: @@ -2891,9 +2887,6 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} @@ -10339,10 +10332,6 @@ snapshots: dependencies: undici-types: 7.19.2 - '@types/node@26.1.1': - dependencies: - undici-types: 8.3.0 - '@types/node@26.1.2': dependencies: undici-types: 8.3.0 @@ -10389,7 +10378,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -10409,7 +10398,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -10426,6 +10415,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -10435,6 +10433,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -10464,6 +10466,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -11776,11 +11793,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -11804,7 +11821,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -11827,7 +11844,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11838,7 +11855,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -15262,6 +15279,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx index 3d723ad4986..95ac00437e2 100644 --- a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -38,6 +38,7 @@ import { import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card' import { styles } from './agent-history-styles' import { useNow } from '../hooks/use-now' +import { useOpenMobileSession } from '../session/use-open-mobile-session' export type MobileAgentSessionHistoryPanelProps = { hostId: string @@ -57,6 +58,7 @@ export function MobileAgentSessionHistoryPanel({ name = '' }: MobileAgentSessionHistoryPanelProps) { const router = useRouter() + const openMobileSession = useOpenMobileSession() const { client, state: connState } = useHostClient(hostId) const [worktrees, setWorktrees] = useState([]) const [worktreesLoaded, setWorktreesLoaded] = useState(false) @@ -163,11 +165,7 @@ export function MobileAgentSessionHistoryPanel({ try { const structuredWorkspaceId = await activateStructuredAiVaultSession(client, session) if (structuredWorkspaceId) { - router.push( - `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(structuredWorkspaceId)}` as Parameters< - typeof router.push - >[0] - ) + openMobileSession({ hostId, worktreeId: structuredWorkspaceId }) return } const { @@ -220,11 +218,7 @@ export function MobileAgentSessionHistoryPanel({ resumeMutationRegistryRef.current.releaseOnSuccess(session.id) triggerSuccess() setResumeMessage('Agent session queued.') - router.push( - `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters< - typeof router.push - >[0] - ) + openMobileSession({ hostId, worktreeId: target.worktreeId }) } catch (err) { triggerError() setResumeMessage(err instanceof Error ? err.message : 'Failed to resume session.') @@ -239,7 +233,7 @@ export function MobileAgentSessionHistoryPanel({ hostId, hostPlatform, hostTerminalWindowsShell, - router, + openMobileSession, worktreeId, worktrees ] diff --git a/mobile/src/session/MobileStructuredQuestionGroupCard.tsx b/mobile/src/session/MobileStructuredQuestionGroupCard.tsx index 5fe570ab3fe..532d3568b3a 100644 --- a/mobile/src/session/MobileStructuredQuestionGroupCard.tsx +++ b/mobile/src/session/MobileStructuredQuestionGroupCard.tsx @@ -8,6 +8,16 @@ import { } from '../../../src/shared/agent-session-question-answer' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +function toggleSortedOption(selected: readonly number[], optionIndex: number): number[] { + return selected.includes(optionIndex) + ? selected.filter((value) => value !== optionIndex) + : [ + ...selected.filter((value) => value < optionIndex), + optionIndex, + ...selected.filter((value) => value > optionIndex) + ] +} + export function MobileStructuredQuestionGroupCard(props: { questions: readonly AgentJournalQuestion[] onAnswer: (encoded: string) => Promise @@ -41,14 +51,11 @@ export function MobileStructuredQuestionGroupCard(props: { const toggleOption = (optionIndex: number): void => { setSelections((current) => { - const next = current.map((selected) => [...selected]) - const selected = next[index] ?? [] - next[index] = question.multiSelect - ? selected.includes(optionIndex) - ? selected.filter((value) => value !== optionIndex) - : [...selected, optionIndex].sort((left, right) => left - right) + const selected = current[index] ?? [] + const nextSelected = question.multiSelect + ? toggleSortedOption(selected, optionIndex) : [optionIndex] - return next + return current.map((value, i) => (i === index ? nextSelected : value)) }) if (!question.multiSelect) { setOtherText((current) => current.map((text, i) => (i === index ? '' : text))) diff --git a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts index 31a1a589470..4fd38fa448f 100644 --- a/mobile/src/session/mobile-structured-agent-session-reducer.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-reducer.test.ts @@ -17,11 +17,28 @@ function item(id: string, sequence: number, revision = 1): AgentJournalRenderIte } function snapshot(epoch: string, items: AgentJournalRenderItem[]) { + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? 0 return { type: 'snapshot' as const, sessionId: 'session-a', fence: 1, - snapshot: { sessionId: 'session-a', cursor: { epoch, sequence: 50 }, items, submissions: [] } + page: { + sessionId: 'session-a', + epoch, + direction: 'tail' as const, + items, + removedItemIds: [], + submissions: [], + window: { + oldest: items[0] ? { epoch, sequence: oldest } : null, + newest: items.at(-1) ? { epoch, sequence: newest } : null, + nextCursor: { epoch, sequence: items.length > 0 ? newest : 0 } + }, + liveCursor: items.at(-1) ? { epoch, sequence: newest } : undefined, + hasOlder: false, + hasNewer: false + } } } @@ -92,8 +109,8 @@ describe('mobile structured session reducer', () => { event: { ...snapshot('epoch-a', []), fence: index + 1, - snapshot: { - ...snapshot('epoch-a', []).snapshot, + page: { + ...snapshot('epoch-a', []).page, cursor: { epoch: 'epoch-a', sequence: index } }, handoff diff --git a/mobile/src/session/mobile-structured-tui-send.ts b/mobile/src/session/mobile-structured-tui-send.ts index 9adc14093f4..0db13ea8330 100644 --- a/mobile/src/session/mobile-structured-tui-send.ts +++ b/mobile/src/session/mobile-structured-tui-send.ts @@ -7,6 +7,7 @@ import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' import { + clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, typeMobileNativeChatCommandWithOutcome, @@ -22,6 +23,7 @@ import { acquireMobileNativeChatTerminalWrite, releaseMobileNativeChatTerminalWrite } from './mobile-native-chat-terminal-write-lock' +import { AGENT_TUI_CLEAR_INPUT_LINE } from '../../../src/shared/agent-tui-input-clear' type MobileStructuredTuiSendArgs = { client: RpcClient @@ -86,23 +88,35 @@ export async function sendMobileStructuredTuiMessage( const textDeadline = args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline const classification = classifyMobileNativeChatSend(args.agent, args.text) - const outcome = + const shouldTypeCommand = 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 (!shouldTypeCommand && args.attachments.length === 0) { + const cleared = await clearMobileNativeChatInput({ + client: args.client, + terminal: args.terminal, + clearInput: AGENT_TUI_CLEAR_INPUT_LINE, + ...(mobileClient ? { mobileClient } : {}), + deadline: textDeadline + }) + if (!cleared) { + return 'rejected' + } + } + const outcome = shouldTypeCommand + ? 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, + ...(mobileClient ? { mobileClient } : {}), + deadline: textDeadline + }) if (args.attachments.length > 0 && outcome !== 'accepted') { markMobileNativeChatInputStale(args.terminal) } diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index aaa46cd4854..651df8d3955 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -37,10 +37,13 @@ function openStructuredAgentSessionSubscription(args: { { sessionId: args.sessionId, ...(args.cursor ? { cursor: args.cursor } : {}) }, args.onEvent, { - paramsForReconnect: () => ({ - sessionId: args.sessionId, - ...(args.resumeCursor() ? { cursor: args.resumeCursor() } : {}) - }) + paramsForReconnect: () => { + const resumeCursor = args.resumeCursor() + return { + sessionId: args.sessionId, + ...(resumeCursor ? { cursor: resumeCursor } : {}) + } + } } ) } @@ -63,13 +66,19 @@ export function useMobileStructuredAgentSession(args: { const { client, sessionId } = args const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION) const stateRef = useRef(state) - stateRef.current = state const resumeCursorRef = useRef(state.cursor) - resumeCursorRef.current = state.cursor const [loadingOlder, setLoadingOlder] = useState(false) const reconnectRef = useRef(createMobileStructuredReconnectState()) const cancelLongevityRef = useRef<() => void>(() => {}) + useEffect(() => { + stateRef.current = state + }, [state]) + + useEffect(() => { + resumeCursorRef.current = state.cursor + }, [state.cursor]) + useEffect(() => { dispatch({ type: 'loading' }) setLoadingOlder(false) @@ -125,7 +134,7 @@ export function useMobileStructuredAgentSession(args: { } if (event.type === 'snapshot' || event.type === 'reset') { setLoadingOlder(false) - resumeCursorRef.current = event.snapshot.cursor + resumeCursorRef.current = event.page.liveCursor ?? event.page.window.nextCursor } else if (event.type === 'batch') { const current = resumeCursorRef.current if (shouldAdvanceStructuredResumeCursor(current, event.batch.cursor)) { @@ -156,8 +165,8 @@ export function useMobileStructuredAgentSession(args: { const result = response.result as AgentSessionHistoryResult if (result.ok) { dispatch({ type: 'tail-page', page: result.page }) - resumeCursorRef.current = result.page.liveCursor ?? null - openSubscription(result.page.liveCursor ?? null) + resumeCursorRef.current = result.page.liveCursor ?? result.page.window.nextCursor + openSubscription(resumeCursorRef.current) return } dispatch({ @@ -166,12 +175,12 @@ export function useMobileStructuredAgentSession(args: { type: 'reset', sessionId, reset: result.reset, - snapshot: result.snapshot, + page: result.page, fence: result.fence ?? 0 } }) - resumeCursorRef.current = result.snapshot.cursor - openSubscription(result.snapshot.cursor) + resumeCursorRef.current = result.page.liveCursor ?? result.page.window.nextCursor + openSubscription(resumeCursorRef.current) }) .catch((error: unknown) => { if (!closed) { @@ -227,7 +236,7 @@ export function useMobileStructuredAgentSession(args: { type: 'reset', sessionId, reset: result.reset, - snapshot: result.snapshot, + page: result.page, fence: result.fence ?? 0 } }) diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts index 4023a1a8e39..25a06b1edf2 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -40,6 +40,11 @@ export class FakeSession implements RpcClient { } export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + private structuredReconnectSignal = { + backgroundRestart: false, + streamLongevityConfirmed: false + } + constructor( state: ConnectionState, private readonly failure: Error | null = null, @@ -61,6 +66,14 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi resumeExpiresAt: this.resumeExpiry }) getFailure = () => this.failure + consumeStructuredReconnectSignal = () => { + const signal = this.structuredReconnectSignal + this.structuredReconnectSignal = { + backgroundRestart: false, + streamLongevityConfirmed: false + } + return signal + } } export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts index 795f2618dfb..4ce65d69ab0 100644 --- a/mobile/src/transport/mobile-relay-runtime-failover.test.ts +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -72,6 +72,11 @@ class FakeSession implements RpcClient { } class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + private structuredReconnectSignal = { + backgroundRestart: false, + streamLongevityConfirmed: false + } + constructor( state: ConnectionState, private readonly failure: Error | null = null @@ -84,6 +89,14 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 getResumeConfirmation = () => null getFailure = () => this.failure + consumeStructuredReconnectSignal = () => { + const signal = this.structuredReconnectSignal + this.structuredReconnectSignal = { + backgroundRestart: false, + streamLongevityConfirmed: false + } + return signal + } } class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 10f586d2780..5ac680b9d90 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -17,6 +17,7 @@ export type SendRequestOptions = { type SubscribeOptions = { onBinaryFrame?: (frame: BrowserScreencastFrame) => void + paramsForReconnect?: () => unknown } type StreamingListener = (result: unknown) => void @@ -43,6 +44,8 @@ export type RpcClient = { getLastInboundAt?: () => number | null onStateChange: (listener: (state: ConnectionState) => void) => () => void notifyForeground: (reason?: ForegroundNudgeReason) => void + confirmStructuredStreamLongevity?: () => void + restartAfterStructuredBackground?: () => void close: () => void } diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index 4799b042c3c..b448db05bc6 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -170,16 +170,10 @@ export function createStableLogicalRpcClient( activeSession.notifyForeground(reason) } }, - restartAfterStructuredBackground: () => { - if (!suspended) { - activeSession.restartAfterStructuredBackground?.() - } - }, - confirmStructuredStreamLongevity: () => { - if (!suspended) { - activeSession.confirmStructuredStreamLongevity?.() - } - }, + restartAfterStructuredBackground: () => + suspended ? undefined : activeSession.restartAfterStructuredBackground?.(), + confirmStructuredStreamLongevity: () => + suspended ? undefined : activeSession.confirmStructuredStreamLongevity?.(), close() { if (closed) { return diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fd2317b8f..1dd8826dd8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.0.0 + version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -56,6 +59,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.0.0': + resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -87,6 +95,17 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true + '@pnpm/exe@12.0.0': + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index d4d70078b9a..fb6f52393a8 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -71,7 +71,11 @@ describe('Claude structured dispatch image limits', () => { type: 'user', parent_tool_use_id: null, session_id: 'provider-session', - uuid: 'user-replay-uuid' + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }] + } }) await expect(dispatched).resolves.toMatchObject({ @@ -80,6 +84,48 @@ describe('Claude structured dispatch image limits', () => { }) }) + it('ignores a top-level tool-result user frame while waiting for a slash command replay', async () => { + const session = sessionFor() + const dispatched = dispatchClaudeTurn( + session, + { clientMessageId: 'client-1', body: userMessage([{ type: 'text', text: '/permissions' }]) }, + 100 + ) + await vi.waitFor(() => expect(session.dispatchWaiters).toHaveLength(1)) + + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'tool-result-uuid', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }] + } + }) + expect(session.dispatchWaiters).toHaveLength(1) + + resolveClaudeReplayWaiter(session, { + type: 'user', + parent_tool_use_id: null, + session_id: 'provider-session', + uuid: 'user-replay-uuid', + message: { + role: 'user', + content: [{ type: 'text', text: '/permissions' }] + } + }) + + await expect(dispatched).resolves.toEqual({ + state: 'accepted', + providerIdentity: { + provider: 'claude', + sessionId: 'provider-session', + uuid: 'user-replay-uuid' + } + }) + }) + it('rejects more than twenty URL images before sending', async () => { const session = sessionFor() const body = userMessage( diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index eb8b8d12645..e1a918e1647 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -3,6 +3,10 @@ import { open } from 'node:fs/promises' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../shared/native-chat-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + claudeHasReplayContent, + readClaudeMessageEnvelope +} from './claude-structured-item-translation' import type { ClaudeSession } from './claude-structured-session-state' import { readClaudeFrameString } from './claude-structured-init-proof' @@ -54,7 +58,11 @@ export function resolveClaudeReplayWaiter( session: ClaudeSession, message: Record ): void { - const isUserReplay = message.type === 'user' && message.parent_tool_use_id === null + const envelope = readClaudeMessageEnvelope(message) + const isUserReplay = + envelope?.role === 'user' && + message.parent_tool_use_id === null && + claudeHasReplayContent(envelope) const isCompletedCommand = message.type === 'result' if ( (!isUserReplay && !isCompletedCommand) || diff --git a/src/main/claude/claude-structured-item-translation.ts b/src/main/claude/claude-structured-item-translation.ts index a69130f5c98..52dd1d25509 100644 --- a/src/main/claude/claude-structured-item-translation.ts +++ b/src/main/claude/claude-structured-item-translation.ts @@ -78,6 +78,13 @@ export function claudeMessageBody(envelope: ClaudeMessageEnvelope): AgentJournal return blocks.length > 0 ? { kind: 'message', role: envelope.role, blocks } : null } +export function claudeHasReplayContent(envelope: ClaudeMessageEnvelope): boolean { + return envelope.content.some((value) => { + const part = claudeRecord(value) + return part !== null && part.type !== 'tool_result' + }) +} + export function claudeToolUses(envelope: ClaudeMessageEnvelope): ClaudeToolUse[] { return envelope.content.flatMap((value) => { const part = claudeRecord(value) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index 68d8982ff8a..1031cb1d10c 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -173,6 +173,31 @@ describe('Claude structured journal translation', () => { }) }) + it('does not start a lifecycle for a top-level user tool result', () => { + const state = sinkState() + const translator = createClaudeJournalTranslator({ sink: state.sink }) + + translator.handle( + message('user', 'tool-result-only', [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'done' } + ]) + ) + + expect(state.items.map((item) => agentJournalItemKey(item.identity))).toEqual([ + 'orca:claude-tool%3Aclaude-session%3Atool-1' + ]) + expect(state.items[0]?.body).toMatchObject({ + kind: 'tool-call', + state: 'completed', + output: { head: 'done' } + }) + expect( + state.items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle !== undefined + ) + ).toBe(false) + }) + it('renders empty user frames through the provider fallback', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) @@ -191,14 +216,14 @@ describe('Claude structured journal translation', () => { ) }) - it('renders every unmodeled Claude frame family as a bounded provider row', () => { + it('renders unmodeled substantive Claude frames as bounded provider rows', () => { const state = sinkState() const translator = createClaudeJournalTranslator({ sink: state.sink }) translator.handle({ type: 'message', sessionId: 'orca-session', - message: { type: 'system', subtype: 'compact_boundary', summary: 'x'.repeat(100_000) } + message: { type: 'system', subtype: 'local_command_output', summary: 'x'.repeat(100_000) } }) translator.handle({ type: 'message', @@ -242,18 +267,22 @@ describe('Claude structured journal translation', () => { ) expect(frames.map((frame) => frame.kind)).toEqual( expect.arrayContaining([ - 'message:system:compact_boundary', - 'message:system:hook_response', + 'message:system:local_command_output', 'message:system:command_started', 'message:result', - 'message:tool_progress', - 'message:prompt_suggestion', 'message:user:content:document', 'control_request:future_control' ]) ) + expect(frames.map((frame) => frame.kind)).not.toEqual( + expect.arrayContaining([ + 'message:system:hook_response', + 'message:tool_progress', + 'message:prompt_suggestion' + ]) + ) expect( - frames.find((frame) => frame.kind === 'message:system:compact_boundary')?.payload + frames.find((frame) => frame.kind === 'message:system:local_command_output')?.payload ).toEqual(expect.objectContaining({ truncated: true, byteLength: expect.any(Number) })) }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 351dd80c2ea..fe4be1e08f9 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -13,6 +13,7 @@ import type { ClaudeStructuredSessionEvent } from './claude-structured-session-s import { claudeMessageBody, claudeMessageIdentity, + claudeHasReplayContent, claudeRecord, claudeStreamingMessageBody, claudeText, @@ -219,7 +220,7 @@ export function createClaudeJournalTranslator( } if ( envelope.role === 'user' && - envelope.content.length > 0 && + claudeHasReplayContent(envelope) && message.parent_tool_use_id === null ) { if (currentTurn) { diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index e04605b4830..5bd38db7179 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -87,22 +87,16 @@ describe('claude structured launch resolution', () => { }) it('keeps the session launch environment pinned after account settings change', async () => { - const pinned = record({ - launchEnv: { - ANTHROPIC_AUTH_TOKEN: 'first-token', - ANTHROPIC_BASE_URL: 'https://gateway.example.test' - } - }) - const resolver = resolverFor(pinned, () => ({ + const resolver = resolverFor(record(), () => ({ ANTHROPIC_AUTH_TOKEN: 'rotated-token', ANTHROPIC_BASE_URL: 'https://gateway.example.test' })) expect((await resolver({ identity: IDENTITY })).env).toEqual({ - ANTHROPIC_AUTH_TOKEN: 'first-token', + ANTHROPIC_AUTH_TOKEN: 'rotated-token', ANTHROPIC_BASE_URL: 'https://gateway.example.test' }) - expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('first-token') + expect((await resolver({ identity: IDENTITY })).env?.ANTHROPIC_AUTH_TOKEN).toBe('rotated-token') }) it('refuses other hosts, WSL, providers, and account-home variables', async () => { diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 7f926ae9762..fdee522e056 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -38,7 +38,10 @@ export type ClaudeStructuredLaunchResolverDeps = { store: AgentSessionRecordStore resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string - resolveEnv?: () => Record + resolveEnv?: () => + | Promise | undefined> + | Record + | undefined } export function claudeSessionIdForOrcaSession(sessionId: string): string { @@ -85,15 +88,12 @@ export function createClaudeStructuredLaunchResolver( ...CLAUDE_STRUCTURED_BASE_ARGS, ...providerArgs ]) + const env = await deps.resolveEnv?.() return { command: spawnCmd, args: spawnArgs, cwd: await deps.resolveWorkspacePath(record.location.workspaceId), - ...(record.launchEnv - ? { env: { ...record.launchEnv } } - : deps.resolveEnv - ? { env: deps.resolveEnv() } - : {}), + ...(env ? { env } : {}), claudeConfigDir: record.accountHome.path, providerSessionId, resumeLeafUuid: head?.handle.provider === 'claude' ? head.handle.leafUuid : null, diff --git a/src/main/claude/claude-structured-prompt-replies.ts b/src/main/claude/claude-structured-prompt-replies.ts index 10d56f33afb..efd6aa6e363 100644 --- a/src/main/claude/claude-structured-prompt-replies.ts +++ b/src/main/claude/claude-structured-prompt-replies.ts @@ -46,7 +46,7 @@ function questionAnswer(prompt: ClaudePendingPrompt, questionId: string, optionI return optionId } const questionIndex = prompt.questionIds.indexOf(questionId) - if (questionIndex < 0) { + if (questionIndex === -1) { return optionId } const choice = /^choice-([1-9]\d*)$/.exec(decoded.answer) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 72a86479eac..64f861aa171 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -102,7 +102,13 @@ export async function readClaudeStructuredSessionOptions( } const effort = session.options.get('effort') ?? session.reportedOptions.effort return { - models: models.map(({ resolvedModel: _, ...entry }) => entry), + models: models.map((entry) => ({ + id: entry.id, + label: entry.label, + ...(entry.description ? { description: entry.description } : {}), + isDefault: entry.isDefault, + efforts: entry.efforts + })), current: { model, ...(effort ? { effort } : {}) } } } diff --git a/src/main/claude/claude-tui-resume-launch.test.ts b/src/main/claude/claude-tui-resume-launch.test.ts index bde0ca3ea70..8e850573682 100644 --- a/src/main/claude/claude-tui-resume-launch.test.ts +++ b/src/main/claude/claude-tui-resume-launch.test.ts @@ -77,14 +77,11 @@ describe('Claude TUI resume launch', () => { const build = createClaudeTuiResumeLaunchBuilder({ resolveWorkspacePath: async () => '/workspace', resolveCommand: () => 'claude', - resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'rotated-token' }), + resolveEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'pinned-token' }), inheritedEnv: {} }) - const launch = await build({ - record: record({ launchEnv: { ANTHROPIC_AUTH_TOKEN: 'pinned-token' } }), - spawnToken: 'spawn' - }) + const launch = await build({ record: record(), spawnToken: 'spawn' }) expect(launch.env.ANTHROPIC_AUTH_TOKEN).toBe('pinned-token') }) diff --git a/src/main/claude/claude-tui-resume-launch.ts b/src/main/claude/claude-tui-resume-launch.ts index 35a6b3fe5fa..de72b27216b 100644 --- a/src/main/claude/claude-tui-resume-launch.ts +++ b/src/main/claude/claude-tui-resume-launch.ts @@ -48,7 +48,7 @@ export function createClaudeTuiResumeLaunchBuilder( '--resume', head.handle.sessionId ]) - const configuredEnv = record.launchEnv ?? deps.resolveEnv?.() ?? {} + const configuredEnv = deps.resolveEnv?.() ?? {} const env = buildClaudeChildProcessEnv( { ...configuredEnv, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index d931327c14d..d5968bbadef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -27,16 +27,18 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return acquired } - async releaseAcquisition(input: { sessionId: string }): Promise { + async releaseAcquisition(input: { sessionId: string }): Promise { const adapter = this.owners.get(input.sessionId) if (adapter) { - await adapter.releaseAcquisition?.(input) + const released = await adapter.releaseAcquisition?.(input) this.owners.delete(input.sessionId) - return + return released === true } + let released = false for (const candidate of Object.values(this.adapters)) { - await candidate.releaseAcquisition?.(input) + released = (await candidate.releaseAcquisition?.(input)) === true || released } + return released } dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) => @@ -62,13 +64,14 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) - async closeSession(sessionId: string): Promise { + async closeSession(sessionId: string): Promise { const adapter = this.owners.get(sessionId) if (!adapter) { - return + return false } - await adapter.closeSession?.(sessionId) + const closed = await adapter.closeSession?.(sessionId) this.owners.delete(sessionId) + return closed === true } async closeAll(): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index cfdbf786e14..9abb1171a4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -5,7 +5,6 @@ // the record store's compare-and-swap, which also owns the idempotency row, so // a retried attach replays instead of reserving a second owner. -import type { AgentType } from '../../../shared/agent-status-types' import type { AgentSessionJournalIdentity, AgentSessionProviderHandle @@ -51,7 +50,7 @@ export type AgentSessionAttachParams = { envelope: AgentSessionMutationEnvelope location: AgentSessionExecutionLocation provider: AgentSessionHandleProvider - agent: AgentType + agent: AgentSessionHandleProvider accountHome: AgentSessionAccountHome runtimeKind: AgentSessionOwnerRuntimeKind /** Omitted only for create-by-intent; the adapter proves the durable handle. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts index 1c6b843d211..87bc33bc4b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-options-round-trip.test.ts @@ -84,10 +84,7 @@ function transport(): StructuredAgentSessionHandoffTransport { record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' ), stopRecoveredOwner: async () => undefined, - waitForTuiExit: async (current, persistHandle) => { - await persistHandle(current.link) - return { transcriptPath: current.transcriptPath } - }, + waitForTuiExit: async (current) => ({ transcriptPath: current.transcriptPath }), waitForTuiIdleOrExit: async () => 'idle', tuiStatus: () => 'idle' } @@ -119,6 +116,7 @@ function adapter(): StructuredAgentSessionAdapter { readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })), closeSession: vi.fn(async () => { activeModel = DEFAULT_MODEL + return true }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts index 83843363fb8..da8d2eaad3d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-operation-guard.ts @@ -1,5 +1,8 @@ import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' -import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionOperationOutcome, + AgentSessionOperationRefusalCode +} from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' type ActiveOperation = { callerKey: string; operationId: string; fingerprint: string } @@ -8,7 +11,7 @@ export type HandoffOperationDecision = | { decision: 'new' } | { decision: 'replay'; outcome: AgentSessionOperationOutcome } | { decision: 'retry' } - | { decision: 'refused'; code: 'agent_session_operation_conflict' | string } + | { decision: 'refused'; code: AgentSessionOperationRefusalCode } export class StructuredAgentSessionHandoffOperationGuard { private readonly activeBySession = new Map() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts index 2b6447a1a14..e5ee4f7ca9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-options.test.ts @@ -158,6 +158,7 @@ function adapter(): StructuredAgentSessionAdapter { })), closeSession: vi.fn(async () => { activeModel = DEFAULT_MODEL + return true }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts index 53c7f6ad254..e5dd478f719 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-test-coordinator.ts @@ -57,7 +57,7 @@ export function createStructuredAgentSessionHandoffTestCoordinator( journal: input.journal, fence: input.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 1 }), - suspendNative: async () => undefined, + suspendNative: async () => ({ state: 'stopped' as const }), acquireNative: input.acquireNative, acquireNativeStop: (_sessionId, turnId) => input.acquireNativeStop(turnId), importTuiHistory: async ({ fence }) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts index 0e213921609..5f371b87c41 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -1,11 +1,37 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { + admitStructuredHandoffRequest, + refuseAdmittedStructuredHandoff, + replayedStructuredHandoffRefusal, + structuredHandoffRetryIsAdmissible +} from './structured-agent-session-handoff-admission' import { createStructuredHandoffFlowContext, requireStructuredHandoffRecord } from './structured-agent-session-handoff-flow-context' -import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { StructuredAgentSessionHandoffFlowRunner } from './structured-agent-session-handoff-flow-runner' +import { StructuredAgentSessionHandoffOperationGuard } from './structured-agent-session-handoff-operation-guard' +import { StructuredAgentSessionHandoffQueue } from './structured-agent-session-handoff-queue' import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { + structuredHandoffRefusal as refusal, + structuredHandoffSuccess +} from './structured-agent-session-handoff-result' +import { + failedStructuredHandoffStatus, + idleStructuredHandoffStatus, + structuredSessionHasPendingPrompt, + structuredTuiStatus +} from './structured-agent-session-handoff-status' import type { StructuredAgentSessionHandoffDeps, StructuredAgentSessionHandoffFlowContext @@ -14,50 +40,263 @@ import { StructuredAgentSessionHandoffState } from './structured-agent-session-h export class StructuredAgentSessionHandoffCoordinator { private readonly state: StructuredAgentSessionHandoffState + private readonly queue = new StructuredAgentSessionHandoffQueue() + private readonly operationGuard: StructuredAgentSessionHandoffOperationGuard + private readonly flowRunner: StructuredAgentSessionHandoffFlowRunner constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { - // oxfmt-ignore - this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel }) + this.state = new StructuredAgentSessionHandoffState({ + requireRecord: (sessionId) => this.requireRecord(sessionId), + publish: deps.publish, + hostLabel: deps.transport?.hostLabel + }) + this.operationGuard = new StructuredAgentSessionHandoffOperationGuard(deps.store) + this.flowRunner = new StructuredAgentSessionHandoffFlowRunner({ + deps, + operationGuard: this.operationGuard, + flowContext: () => this.flowContext(), + fail: (params, error) => this.fail(params, error) + }) } - status = (sessionId: string) => this.state.status(sessionId) + status = (sessionId: string): AgentSessionHandoffStatus => this.state.status(sessionId) + + drain = (): Promise => this.flowRunner.drain() closeRetainedTuiOwner = (sessionId: string): Promise => - closeRetainedTuiOwner({ - sessionId, - deps: this.deps, - owner: this.state.owner, - requireRecord: this.requireRecord, - releaseOwner: this.state.releaseOwner - }) + this.closeRetainedOwner(sessionId) setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => this.state.setStatus(sessionId, status) + async request( + callerKey: string, + params: AgentSessionHandoffRequest + ): Promise> { + const record = this.requireRecord(params.envelope.sessionId) + const currentStatus = this.state.cachedStatus(record.sessionId) + const admission = await admitStructuredHandoffRequest({ + deps: this.deps, + operationGuard: this.operationGuard, + callerKey, + params, + record, + ...(currentStatus ? { status: currentStatus } : {}) + }) + if (admission.decision === 'replay') { + const replayedRefusal = replayedStructuredHandoffRefusal(admission.outcome) + if (replayedRefusal) { + return { ok: false, refusal: replayedRefusal } + } + return this.success(record.sessionId, true) + } + if (admission.decision === 'refused') { + return { ok: false, refusal: admission.refusal } + } + const { fingerprint } = admission + const action = params.action ?? 'start' + if (action === 'cancel-queued') { + if (currentStatus?.phase !== 'queued' || currentStatus?.direction !== params.direction) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'No matching queued handoff exists.' + ) + } + this.queue.cancel(record.sessionId) + this.setStatus(record.sessionId, idleStructuredHandoffStatus(record)) + await this.deps.store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: record.sessionId } + }) + return this.success(record.sessionId, false) + } + if (!this.deps.transport) { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Agent TUI handoff is unavailable on this host.' + ) + } + if (action === 'retry') { + if (!structuredHandoffRetryIsAdmissible(this.status(record.sessionId), params)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_operation_conflict', + 'This handoff is no longer retryable.' + ) + } + this.begin(callerKey, params, null, fingerprint) + return this.success(record.sessionId, false) + } + const expectedOwner = params.direction === 'to-tui' ? 'native' : 'tui' + if (record.lease.runtimeKind !== expectedOwner || record.lease.claimStatus !== 'live') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + `The ${expectedOwner} runtime does not own this session.` + ) + } + if (structuredSessionHasPendingPrompt(this.deps.session(record.sessionId).journal)) { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'Resolve the pending question or approval before switching.' + ) + } + const turnId = activeStructuredAgentSessionTurnId( + this.deps.session(record.sessionId).journal.snapshot().items + ) + const tuiOwner = this.state.owner(record.sessionId) + const busy = + expectedOwner === 'native' + ? turnId !== null + : structuredTuiStatus(tuiOwner, this.deps.transport) !== 'idle' + if (busy && params.mode === 'now') { + return this.refuseAdmitted( + callerKey, + params, + 'agent_session_conflict', + 'The current turn must finish before switching.' + ) + } + if (busy && params.mode === 'after-turn') { + this.queueAfterTurn(callerKey, params, fingerprint) + return this.success(record.sessionId, false) + } + if (busy && expectedOwner === 'tui' && params.mode === 'stop-turn') { + return this.refuseAdmitted( + callerKey, + params, + 'structured_agent_session_unsupported', + 'Exit the agent terminal after this turn to continue in chat.' + ) + } + this.begin(callerKey, params, turnId, fingerprint) + return this.success(record.sessionId, false) + } + async restore(sessionId: string): Promise { await restoreStructuredAgentSessionHandoff( { deps: this.deps, requireRecord: (id) => this.requireRecord(id), flowContext: () => this.flowContext(), - retainOwner: this.state.retainOwner, - setStatus: this.state.setStatus + retainOwner: (id, owner) => this.state.retainOwner(id, owner), + setStatus: (id, status) => this.state.setStatus(id, status) }, sessionId ) } + private refuseAdmitted( + callerKey: string, + params: AgentSessionHandoffRequest, + code: AgentSessionWireRefusal['code'], + message: string + ): Promise> { + return refuseAdmittedStructuredHandoff({ + deps: this.deps, + callerKey, + params, + refusal: refusal(code, message) + }) + } + + private success( + sessionId: string, + replayed: boolean + ): AgentSessionMutationResult { + return structuredHandoffSuccess(this.deps, sessionId, replayed, this.status(sessionId)) + } + + private queueAfterTurn( + callerKey: string, + params: AgentSessionHandoffRequest, + fingerprint: string + ): void { + const sessionId = params.envelope.sessionId + let tuiReadiness: 'idle' | 'exited' | null = null + this.setStatus(sessionId, { + owner: params.direction === 'to-tui' ? 'native' : 'tui', + direction: params.direction, + phase: 'queued', + stage: null, + operationId: params.envelope.clientOperationId, + hostLabel: this.deps.transport?.hostLabel + }) + const tuiOwner = this.state.owner(sessionId) + this.queue.enqueue( + sessionId, + async (signal) => { + if (params.direction === 'to-tui') { + return !activeStructuredAgentSessionTurnId( + this.deps.session(sessionId).journal.snapshot().items + ) + } + tuiReadiness = tuiOwner + ? ((await this.deps.transport?.waitForTuiIdleOrExit(tuiOwner, signal)) ?? null) + : null + return tuiReadiness !== null + }, + () => { + const next = { ...params, mode: 'now' as const } + this.begin(callerKey, next, null, fingerprint, tuiReadiness === 'exited') + } + ) + } + + private begin( + callerKey: string, + params: AgentSessionHandoffRequest, + turnId: string | null, + fingerprint: string, + tuiAlreadyExited = false + ): void { + this.flowRunner.begin({ + callerKey, + params, + turnId, + fingerprint, + tuiAlreadyExited + }) + } + private flowContext(): StructuredAgentSessionHandoffFlowContext { return createStructuredHandoffFlowContext({ deps: this.deps, - owner: this.state.owner, - retainOwner: this.state.retainOwner, - releaseOwner: this.state.releaseOwner, - setStatus: this.state.setStatus, + owner: (sessionId) => this.state.owner(sessionId), + retainOwner: (sessionId, owner) => this.state.retainOwner(sessionId, owner), + releaseOwner: (sessionId) => this.state.releaseOwner(sessionId), + setStatus: (sessionId, status) => this.state.setStatus(sessionId, status), requireRecord: (sessionId) => this.requireRecord(sessionId) }) } + private fail(params: AgentSessionHandoffRequest, error: unknown): void { + const record = this.requireRecord(params.envelope.sessionId) + this.setStatus( + record.sessionId, + failedStructuredHandoffStatus(record, params, error, this.deps.transport?.hostLabel) + ) + } + + private closeRetainedOwner(sessionId: string): Promise { + return closeRetainedTuiOwner({ + sessionId, + deps: this.deps, + owner: this.state.owner, + requireRecord: this.requireRecord, + releaseOwner: this.state.releaseOwner + }) + } + private requireRecord = (sessionId: string): AgentSessionRecord => requireStructuredHandoffRecord(this.deps, sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 47276ea1ba0..94e71f779f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -6,6 +6,8 @@ import type { AgentSessionAttachResult, AgentSessionHistoryRequest, AgentSessionHistoryResult, + AgentSessionHandoffRequest, + AgentSessionHandoffResult, AgentSessionHandoffStatus, AgentSessionMutationResult, AgentSessionOptionsResult, @@ -258,6 +260,19 @@ export class StructuredAgentSessionHost { ): ReturnType => setStructuredAgentSessionOption(this.mutationContext(), caller, params) + requestHandoff = ( + caller: StructuredAgentSessionCaller, + params: AgentSessionHandoffRequest + ): Promise> => + ( + this.handoffs as { + request: ( + callerKey: string, + params: AgentSessionHandoffRequest + ) => Promise> + } + ).request(caller.callerKey, params) + readOptions = (sessionId: string): Promise => readStructuredAgentSessionOptions(this.mutationContext(), sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts index 74631cbe55e..1c40b65131a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-proven-dead-retry.test.ts @@ -158,7 +158,13 @@ describe('structured session proven-dead TUI retry', () => { } expect(coordinator.status(SESSION)).toMatchObject({ phase: 'failed', owner: 'tui' }) - expect(await coordinator.request('client-1', request)).toMatchObject({ ok: true }) + expect( + await ( + coordinator as { + request: (callerKey: string, params: AgentSessionHandoffRequest) => Promise + } + ).request('client-1', request) + ).toMatchObject({ ok: true }) await vi.waitFor(() => expect(coordinator.status(SESSION).owner).toBe('native')) expect(closeTuiOwner).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ diff --git a/src/main/runtime/agent-session-launch-env-backfill.test.ts b/src/main/runtime/agent-session-launch-env-backfill.test.ts index ced0172b37c..c95705f2aa5 100644 --- a/src/main/runtime/agent-session-launch-env-backfill.test.ts +++ b/src/main/runtime/agent-session-launch-env-backfill.test.ts @@ -62,9 +62,9 @@ describe('legacy agent session launch environment', () => { ) const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - expect(reopened.getRecord(SESSION)?.launchEnv).toEqual({ - ANTHROPIC_AUTH_TOKEN: 'pinned-token' - }) + expect( + (reopened.getRecord(SESSION) as { launchEnv?: Record } | null)?.launchEnv + ).toBeUndefined() }) it('rejects an environment that could not be reloaded before writing it', async () => { diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 79cb5205cc7..60983506f49 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -40,6 +40,16 @@ const CLIENT = { clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] } +const { readClaudeTranscriptLeafUuid, resolveSessionFilePath } = vi.hoisted(() => ({ + readClaudeTranscriptLeafUuid: vi.fn(), + resolveSessionFilePath: vi.fn() +})) + +vi.mock('../native-chat/session-file-resolver', () => ({ + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +})) + type FakeClaudeConnection = Omit & { closed: boolean launch: ClaudeStreamJsonLaunch @@ -234,7 +244,7 @@ function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] for (const frame of frames) { const rows = frame.type === 'snapshot' || frame.type === 'reset' - ? frame.snapshot.items + ? frame.page.items : frame.type === 'batch' ? frame.batch.items : [] @@ -256,6 +266,8 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-structured-integration-')) transcriptPath = join(root, 'claude-home', 'projects', 'workspace', `${PROVIDER_SESSION}.jsonl`) await mkdir(join(root, 'claude-home', 'projects', 'workspace'), { recursive: true }) + resolveSessionFilePath.mockResolvedValue(transcriptPath) + readClaudeTranscriptLeafUuid.mockResolvedValue('init-leaf') claude = fakeClaude() tuiOwner = null cleanups = new Map() @@ -287,7 +299,21 @@ beforeEach(async () => { } return tuiOwner }, - reproveTuiOwner: async ({ owner }) => owner, + reproveTuiOwner: async ({ owner }) => { + if (owner.link.handle.provider !== 'claude' || !owner.transcriptPath) { + return owner + } + return { + ...owner, + link: claudeProviderHandleLink({ + sessionId: owner.link.handle.sessionId, + leafUuid: await readClaudeTranscriptLeafUuid(owner.transcriptPath), + resumed: true, + fence: owner.link.mintedAtFence, + observedAt: Date.now() + }) + } + }, recoverTuiOwner: async () => { if (!tuiOwner) { throw new Error('scripted TUI owner missing') @@ -295,18 +321,7 @@ beforeEach(async () => { return tuiOwner }, stopRecoveredOwner: async () => {}, - waitForTuiExit: async (owner, persistHandle) => { - await persistHandle( - claudeProviderHandleLink({ - sessionId: PROVIDER_SESSION, - leafUuid: 'tui-assistant', - resumed: true, - fence: owner.link.mintedAtFence, - observedAt: Date.now() - }) - ) - return { transcriptPath } - }, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), waitForTuiIdleOrExit: async () => 'idle', tuiStatus: () => 'idle', stopFailedTuiLaunch: async () => {} @@ -328,6 +343,7 @@ beforeEach(async () => { resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, resolveCodexCommand: () => '/usr/local/bin/codex', resolveClaudeCommand: () => '/usr/local/bin/claude', + readProcessStartTime: async (pid: number) => pid * 10, resolveClaudeLaunchEnv: () => ({ ANTHROPIC_AUTH_TOKEN: 'configured-token', ANTHROPIC_BASE_URL: 'https://gateway.example.test' @@ -524,6 +540,7 @@ describe('a structured Claude session over agentSession.*', () => { deps: { store: { getRecord: (id: string) => { lease: { runtimeFence: number } } } } } ).deps.store.getRecord(SESSION).lease.runtimeFence + readClaudeTranscriptLeafUuid.mockResolvedValueOnce('tui-assistant') await ok('agentSession.requestHandoff', handoffParams('to-native', tuiFence)) await vi.waitFor(async () => expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native', phase: 'idle' }) diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 083c677e39f..82841f49817 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -52,4 +52,54 @@ describe('structured agent-session create intent', () => { path: '/accounts/selected/home' }) }) + + it('pins the configured Claude launch home without Codex launch preparation', async () => { + const prepareCodexStructuredLaunch = vi.fn() + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { + claude: { CLAUDE_CONFIG_DIR: '/configured/claude-home' } + } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'claude' + }) + + expect(prepareCodexStructuredLaunch).not.toHaveBeenCalled() + expect(intent.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/configured/claude-home' + }) + }) }) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 39275ebedc1..07ed66b3228 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -218,6 +218,56 @@ describe('structured session cold restoration', () => { expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) }) + it('publishes restored Claude tabs with the Claude title', async () => { + const runtime = new OrcaRuntimeService() + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + restoreReadableSessions: async () => undefined, + listSessionTabs: () => [ + { + sessionId: 'agent-session:agent-session:restored-claude', + workspaceId: 'workspace-1', + agent: 'claude' + } + ] + } as never) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(publish).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + sessionId: 'restored-claude', + agent: 'claude', + activate: false, + notify: false + }) + + const restored = await runtime.listMobileSessionTabs('id:workspace-1') + expect(restored.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-claude', + title: 'Claude Chat', + agent: 'claude' + }) + ]) + ) + }) + it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts index 68255a3cb58..c4d96c5cee4 100644 --- a/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts +++ b/src/main/runtime/orca-runtime-structured-tui-tab-binding.test.ts @@ -8,12 +8,16 @@ import { OrcaRuntimeService } from './orca-runtime' const { probeAgentSessionProcessIdentity, proveCodexTuiRollout, + readClaudeTranscriptLeafUuid, readStructuredTuiProcessIdentity, + resolveSessionFilePath, resolvePinnedCodexRolloutProof } = vi.hoisted(() => ({ probeAgentSessionProcessIdentity: vi.fn(), proveCodexTuiRollout: vi.fn(), + readClaudeTranscriptLeafUuid: vi.fn(), readStructuredTuiProcessIdentity: vi.fn(), + resolveSessionFilePath: vi.fn(), resolvePinnedCodexRolloutProof: vi.fn() })) @@ -22,6 +26,10 @@ vi.mock('../codex/codex-tui-rollout-proof', () => ({ proveCodexTuiRollout, resolvePinnedCodexRolloutProof })) +vi.mock('../native-chat/session-file-resolver', () => ({ + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +})) vi.mock('./agent-session-process-identity-probe', async (importOriginal) => ({ ...(await importOriginal()), probeAgentSessionProcessIdentity @@ -111,7 +119,13 @@ describe('structured TUI launch tab binding', () => { getAgentSessionExecutionNamespace(): typeof namespace ptysById: Map< string, - { launchToken: string | null; launchAgent: string | null; agentSessionOwners: unknown[] } + { + launchToken: string | null + launchAgent: string | null + agentSessionOwners: unknown[] + tabId?: string | null + paneKey?: string | null + } > } internal.listResolvedWorktrees = vi.fn(async () => [ @@ -125,6 +139,9 @@ describe('structured TUI launch tab binding', () => { folderWorkspace: null })) internal.getAgentSessionExecutionNamespace = () => namespace + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/sessions/thread-1.jsonl' + }) probeAgentSessionProcessIdentity.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['process-start-time'] @@ -134,6 +151,37 @@ describe('structured TUI launch tab binding', () => { const coldPty = internal.ptysById.get('pty-cold-owner')! expect(coldPty).toMatchObject({ launchToken: null, launchAgent: null }) expect(coldPty.agentSessionOwners).toHaveLength(1) + const runtimeId = (runtime as unknown as { runtimeId: string }).runtimeId + ;( + runtime as unknown as { + handles: Map< + string, + { + handle: string + runtimeId: string + rendererGraphEpoch: number + worktreeId: string + tabId: string + leafId: string + ptyId: string + ptyGeneration: number + } + > + } + ).handles.set(terminalHandle, { + handle: terminalHandle, + runtimeId, + rendererGraphEpoch: 0, + worktreeId: WORKTREE_ID, + tabId: 'pty:pty-cold-owner', + leafId: 'pty:pty-cold-owner', + ptyId: 'pty-cold-owner', + ptyGeneration: 0 + }) + coldPty.tabId = 'tab-cold-owner' + coldPty.paneKey = `tab-cold-owner:${leafId}` + coldPty.launchToken = 'spawn-token' + coldPty.launchAgent = 'codex' const owner = await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner({ sessionId: 'session-1', @@ -157,7 +205,15 @@ describe('structured TUI launch tab binding', () => { paneKey: `tab-cold-owner:${leafId}`, ptyId: 'pty-cold-owner' }) - expect(resolvePinnedCodexRolloutProof).toHaveBeenCalledWith(namespace.providerRoot, 'thread-1') + expect(proveCodexTuiRollout).toHaveBeenCalledWith( + expect.objectContaining({ + codexHome: namespace.providerRoot, + threadId: 'thread-1', + readOutput: expect.any(Function), + write: expect.any(Function) + }) + ) + expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled() expect(writeAgentSessionProof).not.toHaveBeenCalled() expect(agentSessionPtyWriteGate.boundSessionId('pty-cold-owner')).toBe('session-1') agentSessionPtyWriteGate.unbindPty('pty-cold-owner') @@ -181,7 +237,7 @@ describe('structured TUI launch tab binding', () => { state: 'done', prompt: '', agentType: 'claude', - receivedAt: 10, + receivedAt: Date.now() + 1000, stateStartedAt: 10, providerSession: { key: 'session_id', id: sessionId, transcriptPath } } @@ -190,6 +246,7 @@ describe('structured TUI launch tab binding', () => { const internal = runtime as unknown as { createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport ptysById: Map + restoredOrchestrationAuthorityByPtyId: Map } internal.ptysById.set('pty-claude', { ptyId: 'pty-claude', @@ -201,6 +258,8 @@ describe('structured TUI launch tab binding', () => { launchAgent: 'claude', connected: true }) + resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/session.jsonl') + readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-before-resume') const record = { sessionId: 'session-1', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, @@ -231,9 +290,7 @@ describe('structured TUI launch tab binding', () => { const transport = internal.createStructuredAgentSessionHandoffTransport() const recovered = await transport.recoverTuiOwner(record) - const reproved = await transport.reproveTuiOwner({ record, owner: recovered }) - - expect(reproved).toMatchObject({ + expect(recovered).toMatchObject({ transcriptPath, link: { handle: { provider: 'claude', sessionId, leafUuid: 'leaf-before-resume' }, @@ -241,8 +298,47 @@ describe('structured TUI launch tab binding', () => { mintedAtFence: 3 } }) - expect(reproved.link.linkId).not.toBe('claude-old') - expect(attestAgentHookCompatibilityAuthority).not.toHaveBeenCalled() + expect(recovered.link.linkId).not.toBe('claude-old') + + const pty = internal.ptysById.get('pty-claude') as { + launchToken: string | null + } + pty.launchToken = null + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! + internal.restoredOrchestrationAuthorityByPtyId.set('pty-claude', { + ptyId: 'pty-claude', + worktreeId: WORKTREE_ID, + terminalHandle: recovered.terminal.handle, + paneKey: recovered.terminal.paneKey, + processIncarnation: dispatchAuthority.processIncarnation, + hostScope: dispatchAuthority.hostScope + }) + + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toMatchObject({ + paneKey, + terminalHandle: recovered.terminal.handle, + processIncarnation: dispatchAuthority.processIncarnation + }) + expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ + paneKey, + launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), + connectionId: null, + terminalProvenance: 'restored' + }) + attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toBeNull() expect(agentSessionPtyWriteGate.boundSessionId('pty-claude')).toBe('session-1') agentSessionPtyWriteGate.unbindPty('pty-claude') }) @@ -265,7 +361,7 @@ describe('structured TUI launch tab binding', () => { state: 'done', prompt: '', agentType: 'claude', - receivedAt: 10, + receivedAt: Date.now() + 1000, stateStartedAt: 10, providerSession: { key: 'session_id', id: sessionId, transcriptPath } } @@ -286,7 +382,8 @@ describe('structured TUI launch tab binding', () => { launchAgent: 'claude', connected: true }) - internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', {}) + resolveSessionFilePath.mockResolvedValue('/tmp/claude-home/projects/worktree/restored.jsonl') + readClaudeTranscriptLeafUuid.mockResolvedValue('leaf-restored') const record = { sessionId: 'session-restored', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/tmp/claude-home' }, @@ -311,8 +408,34 @@ describe('structured TUI launch tab binding', () => { } } as never - await internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner(record) + const recovered = await internal + .createStructuredAgentSessionHandoffTransport() + .recoverTuiOwner(record) + const restoredPty = internal.ptysById.get('pty-restored') as { + launchToken: string | null + } + restoredPty.launchToken = null + const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(recovered.terminal.handle)! + internal.restoredOrchestrationAuthorityByPtyId.set('pty-restored', { + ptyId: 'pty-restored', + worktreeId: WORKTREE_ID, + terminalHandle: recovered.terminal.handle, + paneKey: recovered.terminal.paneKey, + processIncarnation: dispatchAuthority.processIncarnation, + hostScope: dispatchAuthority.hostScope + }) + expect( + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toMatchObject({ + paneKey, + terminalHandle: recovered.terminal.handle, + processIncarnation: dispatchAuthority.processIncarnation + }) expect(attestAgentHookCompatibilityAuthority).toHaveBeenCalledWith({ paneKey, launchTokenHash: createHash('sha256').update(spawnToken).digest('hex'), @@ -321,8 +444,12 @@ describe('structured TUI launch tab binding', () => { }) attestAgentHookCompatibilityAuthority.mockReturnValueOnce(null as never) await expect( - internal.createStructuredAgentSessionHandoffTransport().recoverTuiOwner(record) - ).rejects.toThrow('launch-token authority') + runtime.verifyOrchestrationCompatibilityCaller({ + terminalHandle: recovered.terminal.handle, + paneKey, + launchToken: spawnToken + }) + ).toBeNull() agentSessionPtyWriteGate.unbindPty('pty-restored') }) @@ -375,7 +502,7 @@ describe('structured TUI launch tab binding', () => { }> markLocalWorkspaceTrustedForAgent(): void waitForTerminal(): Promise - waitForStructuredTuiProof(): Promise<{ transcriptPath?: string }> + waitForAdoptedStructuredTuiProof(): Promise<{ transcriptPath?: string }> waitForStructuredTuiPtyExit(): Promise closeTerminal(handle: string): Promise handles: Map< @@ -398,7 +525,7 @@ describe('structured TUI launch tab binding', () => { internal.markLocalWorkspaceTrustedForAgent = vi.fn() const waitForTerminal = vi.fn(async () => ({})) internal.waitForTerminal = waitForTerminal - const waitForStructuredTuiProof = vi.fn(async () => { + const waitForAdoptedStructuredTuiProof = vi.fn(async () => { const snapshot = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) expect(snapshot.tabs).toContainEqual( expect.objectContaining({ @@ -412,7 +539,7 @@ describe('structured TUI launch tab binding', () => { expect(revealTerminalSession).not.toHaveBeenCalled() return { transcriptPath: '/tmp/rollout.jsonl' } }) - internal.waitForStructuredTuiProof = waitForStructuredTuiProof + internal.waitForAdoptedStructuredTuiProof = waitForAdoptedStructuredTuiProof const waitForStructuredTuiPtyExit = vi.fn(async () => {}) internal.waitForStructuredTuiPtyExit = waitForStructuredTuiPtyExit const closeTerminal = vi.fn(async () => undefined) @@ -458,7 +585,7 @@ describe('structured TUI launch tab binding', () => { expect.any(String), expect.objectContaining({ condition: 'tui-idle' }) ) - expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() expect(onSpawned).toHaveBeenCalledWith( expect.objectContaining({ terminal: expect.objectContaining({ ptyId: 'pty-structured' }), @@ -468,7 +595,7 @@ describe('structured TUI launch tab binding', () => { expect(onSpawned.mock.invocationCallOrder[0]).toBeLessThan( waitForTerminal.mock.invocationCallOrder[0]! ) - expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( + expect(waitForAdoptedStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan( revealTerminalSession.mock.invocationCallOrder[0]! ) const launchCommand = spawn.mock.calls[0]?.[0]?.command @@ -553,9 +680,9 @@ describe('structured TUI launch tab binding', () => { paneKey: owner.terminal.paneKey }) expect(rebound.terminal.handle).not.toBe(owner.terminal.handle) - await transport.waitForTuiExit(rebound, async () => {}) + await transport.waitForTuiExit(rebound) expect(waitForStructuredTuiPtyExit).toHaveBeenCalledWith('pty-structured') - expect(waitForStructuredTuiProof).toHaveBeenCalledOnce() + expect(waitForAdoptedStructuredTuiProof).toHaveBeenCalledOnce() await expect(transport.closeTuiOwner?.(rebound)).resolves.toEqual({ transcriptPath: '/tmp/rollout.jsonl' @@ -569,4 +696,33 @@ describe('structured TUI launch tab binding', () => { ).resolves.toBe('exited') await expect(transport.stopFailedTuiLaunch?.(rebound)).resolves.toBeUndefined() }) + + it('reveals Claude structured native sessions into the mobile graph', () => { + const runtime = new OrcaRuntimeService() + const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const focusEditorTab = vi.fn() + runtime.setNotifier({ focusEditorTab } as never) + const internal = runtime as unknown as { + createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport + } + + internal.createStructuredAgentSessionHandoffTransport().revealNativeSession?.({ + workspaceId: WORKTREE_ID, + sessionId: 'session-claude', + agent: 'claude' + }) + + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKTREE_ID, + sessionId: 'session-claude', + agent: 'claude', + activate: false + }) + ) + expect(focusEditorTab).toHaveBeenCalledWith( + 'structured-agent-session-session-claude', + WORKTREE_ID + ) + }) }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 77b63f3819e..c597a0a5c2e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -650,6 +650,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { @@ -11324,15 +11325,34 @@ export class OrcaRuntimeService { // in a plain folder lands in the folder rather than failing to resolve. resolveWorkspacePath: async (workspaceId) => (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, - resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), + resolveLaunchArgs: (provider) => this.resolveConfiguredStructuredLaunchArgs(provider), resolveLaunchEnvOverlay: () => resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), + resolveClaudeLaunchEnv: () => + resolveTuiAgentLaunchEnv('claude', this.requireStore().getSettings().agentDefaultEnv), handoffTransport: this.createStructuredAgentSessionHandoffTransport() }) } - private resolveConfiguredCodexStructuredArgs(): string[] { + private resolveConfiguredStructuredLaunchArgs( + provider: AgentSessionRecord['provider'] + ): string[] { const settings = this.requireStore().getSettings() + if (provider === 'claude') { + const shell = resolveStartupShell( + process.platform, + resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + ) + const tokenized = tokenizeStartupCommand( + resolveTuiAgentLaunchArgs('claude', settings.agentDefaultArgs), + shell + ) + return tokenized.ok ? tokenized.tokens : [] + } const shell = resolveLocalWindowsAgentStartupShell({ platform: process.platform, isRemote: false, @@ -11763,7 +11783,7 @@ export class OrcaRuntimeService { tuiStatus: (owner) => this.structuredTuiStatus(owner), closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), revealNativeSession: ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { - if (adoptedTerminal || agent !== 'codex') { + if (adoptedTerminal) { return } this.publishStructuredAgentSessionTab({ @@ -12174,7 +12194,7 @@ export class OrcaRuntimeService { async getStructuredAgentSessionCreateSupport( worktreeSelector: string, - agent: 'codex' + agent: 'claude' | 'codex' ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) await this.ensureStructuredAgentSessionHost() @@ -12239,8 +12259,14 @@ export class OrcaRuntimeService { async resolveStructuredAgentSessionCreateIntent(input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }): Promise { + if (input.agent === 'claude') { + return this.resolveStructuredAgentSessionIntent( + input, + async ({ launchEnv }) => launchEnv.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), '.claude') + ) + } return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { // A create has no process yet, so the current selection is what it must follow. const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) @@ -12257,7 +12283,7 @@ export class OrcaRuntimeService { input: { envelope: { sessionId: string; clientOperationId: string } worktree: string - agent: 'codex' + agent: 'claude' | 'codex' }, resolveAccountHomePath: (context: { workspacePath: string @@ -12283,7 +12309,7 @@ export class OrcaRuntimeService { provider: input.agent, agent: input.agent, accountHome: { - variable: 'CODEX_HOME', + variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', path: await resolveAccountHomePath({ workspacePath, launchEnv }) }, runtimeKind: 'native' @@ -12338,16 +12364,12 @@ export class OrcaRuntimeService { } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() for (const session of host?.listSessionTabs() ?? []) { - if (session.agent !== 'codex') { - continue - } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) } this.publishStructuredAgentSessionTab({ ...session, - agent: 'codex', sessionId, activate: false, notify: false @@ -12358,7 +12380,7 @@ export class OrcaRuntimeService { publishStructuredAgentSessionTab(input: { workspaceId: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' activate: boolean notify?: boolean }): void { @@ -12370,7 +12392,7 @@ export class OrcaRuntimeService { const tab: RuntimeMobileSessionAgentTab = { type: 'agent-session', id, - title: 'Codex Chat', + title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', sessionId: input.sessionId, agent: input.agent, isActive: input.activate diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index e713f74f057..bc3d96da79b 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -103,7 +103,7 @@ describe('projectSessionTabAgentStatus', () => { expect(capable).toBe(snapshot) }) - it('withholds legacy Claude rows from paired structured clients', () => { + it('keeps structured Claude and Codex rows visible to paired structured clients', () => { const snapshot = { ...makeSnapshot(false), tabs: [ @@ -132,7 +132,7 @@ describe('projectSessionTabAgentStatus', () => { projectSessionTabAgentStatus(snapshot, 'runtime', [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]).tabs.map((tab) => tab.id) - ).toEqual(['agent-session:codex']) + ).toEqual(['agent-session:codex', 'agent-session:claude']) }) it('withholds session boundaries from legacy paired clients', () => { diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index ac8cc0b2164..f786f817ada 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -22,9 +22,6 @@ export function projectSessionTabAgentStatus true) - if (structuredVisible && clientKind !== undefined) { - projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') - } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( clientKind !== 'runtime' || diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 6a9372ed2c1..4f7c1b20cc3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -98,7 +98,7 @@ export const CreateIntentParams = z .object({ envelope: MutationEnvelope, worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -107,7 +107,7 @@ export const CreateParams = z.union([AttachParams, CreateIntentParams]) export const CreateSupportParams = z .object({ worktree: Identifier('Invalid worktree selector'), - agent: z.literal('codex') + agent: z.enum(['claude', 'codex']) }) .strict() @@ -170,6 +170,15 @@ export const SetOptionParams = z }) .strict() +export const HandoffParams = z + .object({ + envelope: MutationEnvelope, + direction: z.enum(['to-tui', 'to-native']), + mode: z.enum(['now', 'after-turn', 'stop-turn']), + action: z.enum(['start', 'cancel-queued', 'retry', 'recover']).optional() + }) + .strict() + export const OptionsParams = z.object({ sessionId: SessionId }).strict() /** One surface's claim on one session. The id names the surface, not the client: two chat views 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 4da598ff876..6369c705bdd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -97,6 +97,21 @@ function hostStub(): StructuredAgentSessionHost { cancel: vi.fn(async () => ({ ok: true, replayed: false })), respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + status: { + owner: 'native', + direction: null, + phase: 'idle', + stage: null, + operationId: null + } + } + })), handoffStatus: vi.fn(async () => ({ owner: 'native' })), readOptions: vi.fn(async () => ({ models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], @@ -120,9 +135,12 @@ function dispatcher(): RpcDispatcher { workspaceId: 'workspace-1', workspaceKind: 'git-worktree' }, - provider: 'codex', - agent: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + provider: params.agent, + agent: params.agent, + accountHome: { + variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex' + }, runtimeKind: 'native' })), publishStructuredAgentSessionTab: vi.fn() @@ -193,7 +211,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(16) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -282,6 +300,49 @@ describe('method routing', () => { ) }) + it('routes Claude create support and create through the provider-aware runtime', async () => { + const worktree = 'id:workspace-1' + const support = await call( + 'agentSession.createSupport', + { worktree, agent: 'claude' }, + STRUCTURED_CLIENT + ) + expect(support).toMatchObject({ ok: true, result: { supported: true } }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).toHaveBeenCalledWith( + worktree, + 'claude' + ) + + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'claude' } + }) + }), + worktree, + agent: 'claude' + } + const created = await call('agentSession.create', params, STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true, result: { ok: true } }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } + }) + ) + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: SESSION, + activate: true, + agent: 'claude' + }) + ) + }) + it('separates create from ensure by the fence the client may declare', async () => { const created = await call('agentSession.create', attachParams()) expect(created).toMatchObject({ ok: true }) @@ -308,7 +369,7 @@ describe('method routing', () => { ]) }) - it('does not register the structured handoff mutation', async () => { + it('routes the structured handoff mutation through the host', async () => { const response = await call('agentSession.requestHandoff', { envelope: envelope(), direction: 'to-tui', @@ -316,7 +377,11 @@ describe('method routing', () => { action: 'start' }) - expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.requestHandoff).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ direction: 'to-tui', mode: 'now', action: 'start' }) + ) }) }) @@ -356,25 +421,6 @@ describe('parameter validation', () => { ) }) - it('rejects Claude structured create shapes', async () => { - await rejects('agentSession.createSupport', { - worktree: 'id:workspace-1', - agent: 'claude' - }) - const fields = { worktree: 'id:workspace-1', agent: 'claude' } - await rejects('agentSession.create', { - envelope: envelope({ - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.create', - sessionId: SESSION, - fields - }) - }), - ...fields - }) - }) - it('requires a sha256 fingerprint and a positive fence', async () => { await rejects( 'agentSession.send', diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 0f007006109..4cba30555ed 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -18,6 +18,7 @@ import { structuredCallerFor as callerFor, supportsStructuredSessions } from './structured-agent-session-gate' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { AttachParams, @@ -25,6 +26,7 @@ import { CreateParams, CreateSupportParams, HistoryParams, + HandoffParams, HandoffStatusParams, OptionsParams, RespondParams, @@ -86,22 +88,31 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ } }) await ensureHostInstalled(ctx) - const result = await requireHost(ctx).attach(callerFor(ctx), { - ...resolved, + const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved + const attachParams: AgentSessionAttachParams = { + ...resolvedAttach, + provider: resolved.provider as 'claude' | 'codex', + agent: resolved.agent as 'claude' | 'codex', envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } - }) - if (result.ok && resolved.agent === 'codex') { + } + const result = await requireHost(ctx).attach(callerFor(ctx), attachParams) + if (result.ok) { ctx.runtime.publishStructuredAgentSessionTab({ workspaceId: resolved.location.workspaceId, sessionId: result.value.sessionId, - agent: 'codex', + agent: resolved.agent as 'claude' | 'codex', activate: true }) } return result } await ensureHostInstalled(ctx) - return requireHost(ctx).attach(callerFor(ctx), params) + const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params + return requireHost(ctx).attach(callerFor(ctx), { + ...attachWithoutAgent, + provider: params.provider as 'claude' | 'codex', + agent: params.agent as 'claude' | 'codex' + } as AgentSessionAttachParams) } }), defineMethod({ @@ -109,7 +120,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ params: AttachParams, handler: async (params, ctx) => { await ensureHostInstalled(ctx) - return requireHost(ctx).attach(callerFor(ctx), params) + const { agent: _attachAgent, provider: _attachProvider, ...attachWithoutAgent } = params + return requireHost(ctx).attach(callerFor(ctx), { + ...attachWithoutAgent, + provider: params.provider as 'claude' | 'codex', + agent: params.agent as 'claude' | 'codex' + } as AgentSessionAttachParams) } }), defineMethod({ @@ -149,6 +165,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/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 2226fd35b6e..584e3f4b0de 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -16,7 +16,15 @@ import { CodexStructuredSessionAdapter, type CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' +import { createClaudeStructuredLaunchResolver } from '../claude/claude-structured-launch-resolution' +import { + ClaudeStructuredSessionAdapter, + type ClaudeStructuredSessionAdapterDeps +} from '../claude/claude-structured-session-adapter' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' +import { resolveClaudeCommand } from '../codex-cli/command' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { StructuredAgentSessionAdapterRouter } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router' import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' @@ -55,13 +63,16 @@ export type StructuredAgentSessionRuntimeDeps = { claimKeyId: string resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + resolveClaudeCommand?: () => string /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] resolveLaunchEnv?: () => Promise resolveLaunchEnvOverlay?: () => Promise> | Record + resolveClaudeLaunchEnv?: () => Promise> | Record resolveEnvironment?: () => Promise resolveCodexOverrides?: () => NodeJS.ProcessEnv onError?: (input: { scope: string; error: unknown }) => void @@ -71,7 +82,7 @@ export type StructuredAgentSessionRuntimeDeps = { type InstalledRuntime = { host: StructuredAgentSessionHost - adapter: CodexStructuredSessionAdapter + adapter: { closeAll(): Promise } } let installing: Promise | null = null @@ -110,7 +121,7 @@ export async function stopStructuredAgentSessionRuntime(): Promise { async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)() - const resolveEnvironment = async (): Promise => ({ + const resolveCodexEnvironment = async (): Promise => ({ ...(await bootEnvironment), ...(await deps.resolveLaunchEnv?.()), ...(await deps.resolveLaunchEnvOverlay?.()), @@ -141,13 +152,43 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const currentFence = store.getRecord(sessionId)?.lease.runtimeFence ?? fence + const observedAt = Date.now() + await store.transitionHandoff(sessionId, (record) => + recordAgentSessionProviderHandle({ + record, + fence: currentFence, + link: claudeProviderHandleLink({ + sessionId: providerSessionId, + leafUuid, + resumed: true, + fence: currentFence, + observedAt + }), + now: observedAt + }) + ) + }, + ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) + const adapter = new StructuredAgentSessionAdapterRouter({ codex, claude }, async () => { + await Promise.all([codex.closeAll(), claude.closeAll()]) + }) const host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 15f84eb2a26..9a1b55aef4c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3304,7 +3304,10 @@ "ec2adf093e": "Launch {{value0}} in a new terminal", "465e432ef1": "Could not build launch command for {{value0}}.", "e518f544b1": "No agents detected", - "8dea9b5cdf": "No enabled agents" + "8dea9b5cdf": "No enabled agents", + "f378201fbd": "Could not create chat session", + "a847779775": "Start {{value0}} without a terminal", + "0ea6a78efb": "Chat session" }, "RecentTabSwitcher": { "329638ff6f": "Switch Tab", @@ -16641,7 +16644,39 @@ }, "structuredSessionCloseFailed": "Could not close this Codex chat", "structuredSessionLaunchFailed": "Could not open Codex chat", - "structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable." + "structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable.", + "handoff": { + "stage": { + "finishingChat": "Finishing chat session…", + "finishingTerminal": "Finishing agent terminal…", + "openingTerminal": "Opening agent terminal…", + "resumingChat": "Resuming chat session…", + "verifyingTerminal": "Verifying agent terminal…", + "verifyingChat": "Verifying chat session…", + "recovering": "Recovering agent session…", + "manualRecovery": "Agent session needs recovery" + }, + "switchingOwner": "Switching session owner…", + "mode": { + "switching": "Switching", + "terminal": "Terminal", + "chat": "Chat" + }, + "switchingAfterTurn": "Switching after this turn", + "returningAfterTurn": "Returning after this turn", + "cancel": "Cancel", + "switchAfterTurn": "Switch after this turn", + "stopTurnAndSwitch": "Stop turn and switch", + "openAgentTui": "Open agent TUI", + "returnAfterTurn": "Return after this turn", + "returnToChat": "Return to chat", + "agentOpenOnHost": "Agent is open in terminal on {{value0}}.", + "agentOpen": "Agent is open in terminal.", + "exitTerminal": "Exit the agent terminal to continue in chat.", + "retryProof": "Retry proof", + "retry": "Retry", + "details": "Details" + } }, "tab": { "bar": { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b74ee640fca..9e3d53751f6 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -127,91 +127,6 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.activeTabTypeByWorktree?.[WT]).toBe('terminal') }) - it('projects structured agent sessions as native unified tabs', () => { - const agentTab = { - type: 'agent-session' as const, - id: 'agent-session:session-1', - title: 'Codex Chat', - sessionId: 'session-1', - agent: 'codex' as const, - isActive: true - } - const patch = applyWebSessionTabsSnapshot( - makeState(), - makeSnapshot([agentTab], { - activeTabId: agentTab.id, - activeTabType: 'agent-session', - tabGroups: [ - { - id: 'host-group-1', - activeTabId: agentTab.id, - tabOrder: [agentTab.id] - } - ] - }), - ENV, - NOW - ) - - expect(patch.unifiedTabsByWorktree?.[WT]).toEqual([ - expect.objectContaining({ - id: 'structured-agent-session-session-1', - entityId: 'session-1', - contentType: 'agent-session', - agentSessionAgent: 'codex' - }) - ]) - expect(patch.activeTabTypeByWorktree?.[WT]).toBe('agent-session') - expect( - resolveHostSessionTabIdForWebSessionTab( - { ...makeState(), ...patch }, - { environmentId: ENV, worktreeId: WT, tabId: 'structured-agent-session-session-1' } - ) - ).toBe(agentTab.id) - }) - - it('removes a restored structured tab when the host publishes no structured sessions', () => { - const structuredTab: Tab = { - id: 'structured-agent-session-session-1', - entityId: 'session-1', - groupId: 'host-group-1', - worktreeId: WT, - contentType: 'agent-session', - agentSessionAgent: 'codex', - label: 'Codex Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: NOW - } - const patch = applyWebSessionTabsSnapshot( - makeState({ - activeTabId: structuredTab.id, - activeTabIdByWorktree: { [WT]: structuredTab.id }, - activeTabType: 'agent-session', - activeTabTypeByWorktree: { [WT]: 'agent-session' }, - unifiedTabsByWorktree: { [WT]: [structuredTab] }, - tabBarOrderByWorktree: { [WT]: [structuredTab.id] }, - groupsByWorktree: { - [WT]: [ - { - id: 'host-group-1', - worktreeId: WT, - activeTabId: structuredTab.id, - tabOrder: [structuredTab.id] - } - ] - } - }), - makeSnapshot([], { activeTabType: null }), - ENV, - NOW - ) - - expect(patch.unifiedTabsByWorktree?.[WT]).toBeUndefined() - expect(patch.activeTabTypeByWorktree?.[WT]).toBe('terminal') - }) - it('ignores stale or duplicate same-epoch snapshots after a newer version was applied', () => { const state = makeState() const newer = makeSnapshot([], { snapshotVersion: 3, activeTabType: null }) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 2b1ab5404fc..aea07cd3ee8 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -63,7 +63,24 @@ const Block = z.union([ z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) ]) -const PromptOption = z.object({ id: z.string(), label: z.string() }) +const PromptOption = z + .object({ + id: z.string(), + label: z.string(), + description: z.string().optional() + }) + .strict() + +const Question = z + .object({ + id: z.string(), + question: z.string(), + header: z.string().optional(), + multiSelect: z.boolean(), + options: z.array(PromptOption), + freeTextQuestionId: z.string().optional() + }) + .strict() const Resolution = z.object({ state: z.string().min(1), @@ -100,6 +117,7 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ kind: z.literal('question'), question: z.string(), options: z.array(PromptOption), + questions: z.array(Question).optional(), freeTextQuestionId: z.string().optional(), resolution: Resolution }), diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 17184f00349..ce35ffddcab 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -111,6 +111,17 @@ export type AgentJournalResolution = { export type AgentJournalPromptOption = { id: string label: string + description?: string +} + +export type AgentJournalQuestion = { + id: string + question: string + header?: string + multiSelect: boolean + options: AgentJournalPromptOption[] + /** Present when the provider accepts an answer outside the offered options. */ + freeTextQuestionId?: string } export type AgentJournalApprovalItem = { @@ -125,6 +136,7 @@ export type AgentJournalQuestionItem = { kind: 'question' question: string options: AgentJournalPromptOption[] + questions?: AgentJournalQuestion[] /** Present when the provider accepts an answer outside the offered options. */ freeTextQuestionId?: string resolution: AgentJournalResolution diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 18f582fcfaa..02a2464adf8 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -123,6 +123,10 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = // can neither display nor drive. The host also refuses every agentSession.* // method from a connection that does not advertise this. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: mobile clients advertise Claude-structured session support during E2EE +// pairing so the desktop can keep structured-specific affordances enabled. +export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = + 'agent-session.structured.claude.v1' as const // Why: paired structured clients explicitly hold every visible session surface, allowing the host // to stop provider children after the last surface closes without tying lifetime to a transport. export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts index 40f60230218..2f5c256ce31 100644 --- a/src/shared/runtime-mobile-session-tab-contracts.ts +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -91,7 +91,7 @@ export type RuntimeMobileSessionAgentTab = { id: string title: string sessionId: string - agent: 'codex' + agent: 'claude' | 'codex' color?: string | null isPinned?: boolean isActive: boolean diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index ccc80475c93..79c82095f29 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -26,6 +26,33 @@ export function structuredAgentSessionPayloadFingerprint(input: { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') } +export function structuredAgentSessionCreateFingerprint(input: { + sessionId: string + worktree: string + agent: 'claude' | 'codex' +}): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: input.sessionId, + fields: { + worktree: input.worktree, + agent: input.agent + } + }) +} + +export function showStructuredAgentSessionChoice(input: { + hostCapability: boolean + workspaceSupport: boolean + agent: string +}): boolean { + return ( + input.hostCapability && + input.workspaceSupport && + (input.agent === 'claude' || input.agent === 'codex') + ) +} + export function createStructuredAgentSessionOperationId( randomUuid: () => string, now: number = Date.now()