From 0c74172fcd816432e0ff127507136a38ffedfde1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:14:30 -0700 Subject: [PATCH] fix structured Codex TUI adoption --- ...runtime-structured-tui-tab-binding.test.ts | 70 ++++- src/main/runtime/orca-runtime.ts | 81 +++--- .../use-structured-agent-session-create.ts | 110 -------- src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../src/i18n/native-chat-locales.test.ts | 4 + .../src/lib/launch-agent-in-new-tab.test.ts | 17 +- .../src/lib/launch-agent-in-new-tab.ts | 19 +- .../structured-native-chat-availability.ts | 11 + .../structured-native-chat-toggle.test.ts | 252 +++++++++++++++--- .../slices/structured-native-chat-toggle.ts | 68 ++++- 14 files changed, 428 insertions(+), 209 deletions(-) delete mode 100644 src/renderer/src/components/native-chat/use-structured-agent-session-create.ts create mode 100644 src/renderer/src/lib/structured-native-chat-availability.ts 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 98575cb051c..d32752d83a2 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 @@ -106,13 +106,16 @@ describe('structured TUI launch tab binding', () => { const runtime = terminalAdoptionHarness() const adoptTuiOwner = vi.fn(async () => ({ ok: true, replayed: false, fence: 1 })) setStructuredAgentSessionHost({ adoptTuiOwner } as never) - resolvePinnedCodexRolloutProof.mockResolvedValueOnce('/tmp/codex-home/rollout.jsonl') - readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/rollout.jsonl' + }) + const process = { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-adopt' - }) + } + readStructuredTuiProcessIdentity.mockResolvedValueOnce(process).mockResolvedValueOnce(process) try { await expect( @@ -147,6 +150,10 @@ describe('structured TUI launch tab binding', () => { }) ) expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBe('session-adopt') + expect(proveCodexTuiRollout).toHaveBeenCalledWith( + expect.objectContaining({ threadId: 'thread-adopt' }) + ) + expect(resolvePinnedCodexRolloutProof).not.toHaveBeenCalled() } finally { agentSessionPtyWriteGate.unbindPty('pty-adopt') setStructuredAgentSessionHost(null) @@ -155,7 +162,15 @@ describe('structured TUI launch tab binding', () => { it('refuses adoption when the provider rollout cannot be proved', async () => { const runtime = terminalAdoptionHarness() - resolvePinnedCodexRolloutProof.mockResolvedValueOnce(null) + proveCodexTuiRollout.mockRejectedValueOnce( + new Error('The agent terminal did not prove the expected Codex rollout.') + ) + readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + }) await expect( runtime.adoptStructuredAgentSessionTerminal( @@ -174,7 +189,47 @@ describe('structured TUI launch tab binding', () => { }, { callerKey: 'renderer-1' } ) - ).rejects.toThrow('Could not find the rollout') + ).rejects.toThrow('did not prove the expected Codex rollout') + expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() + }) + + it('refuses a thread proof when the Codex process changes during it', async () => { + const runtime = terminalAdoptionHarness() + proveCodexTuiRollout.mockResolvedValueOnce({ + transcriptPath: '/tmp/codex-home/rollout.jsonl' + }) + readStructuredTuiProcessIdentity + .mockResolvedValueOnce({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-adopt' + }) + .mockResolvedValueOnce({ + hostId: 'local', + pid: 5252, + processStartTimeMs: 1_700_000_010_000, + spawnToken: 'spawn-adopt' + }) + + await expect( + runtime.adoptStructuredAgentSessionTerminal( + { + envelope: { + sessionId: 'session-process-swap', + clientOperationId: 'operation-process-swap', + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${WORKTREE_ID}`, + tabId: 'tab-adopt', + paneKey: 'tab-adopt:leaf-adopt', + ptyId: 'pty-adopt', + threadId: 'thread-adopt' + }, + { callerKey: 'renderer-1' } + ) + ).rejects.toThrow('process changed during conversation proof') expect(agentSessionPtyWriteGate.boundSessionId('pty-adopt')).toBeNull() }) @@ -186,12 +241,13 @@ describe('structured TUI launch tab binding', () => { threadId: 'thread-discovered', transcriptPath: '/tmp/codex-home/discovered.jsonl' }) - readStructuredTuiProcessIdentity.mockResolvedValueOnce({ + const process = { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken: 'spawn-adopt' - }) + } + readStructuredTuiProcessIdentity.mockResolvedValueOnce(process).mockResolvedValueOnce(process) try { await expect( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index be56eddc102..671f266d2eb 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11138,50 +11138,57 @@ export class OrcaRuntimeService { if (!listing?.rootProcessId) { throw new Error('Could not prove the Codex terminal process.') } + const rootProcessId = listing.rootProcessId const spawnToken = randomUUID() - const process = await readStructuredTuiProcessIdentity({ - hostId: intent.location.executionHostId, - rootPid: listing.rootProcessId, - spawnToken, - agent: 'codex' - }) - let threadId = input.threadId - let transcriptPath = threadId - ? await resolvePinnedCodexRolloutProof(intent.accountHome.path, threadId) - : null - if (!threadId) { - const readPty = (): RuntimePtyWorktreeRecord => { - const current = this.ptysById.get(input.ptyId) - if (!current?.connected || current.paneKey !== input.paneKey) { - throw new Error('The Codex terminal lost its pane identity.') - } - return current + const readPty = (): RuntimePtyWorktreeRecord => { + const current = this.ptysById.get(input.ptyId) + if (!current?.connected || current.paneKey !== input.paneKey) { + throw new Error('The Codex terminal lost its pane identity.') } + return current + } + const readProcess = () => + readStructuredTuiProcessIdentity({ + hostId: intent.location.executionHostId, + rootPid: rootProcessId, + spawnToken, + agent: 'codex' + }) + const process = await readProcess() + let threadId = input.threadId + const proofInput = { + codexHome: intent.accountHome.path, + kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(input.ptyId)?.flags ?? 0, + readOutput: () => { + const current = readPty() + return { + text: buildTerminalWaitText(current.tailBuffer, current.tailPartialLine, current.preview), + lastOutputAt: current.lastOutputAt + } + }, + write: (data: string) => + this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { + sessionId: input.envelope.sessionId, + spawnToken + }) ?? false + } + let transcriptPath: string + if (threadId) { + const proof = await proveCodexTuiRollout({ ...proofInput, threadId }) + transcriptPath = proof.transcriptPath + } else { const proof = await resolveLiveCodexTuiRollout({ - codexHome: intent.accountHome.path, - kittyKeyboardFlags: this.providerModeTrackersByPtyId.get(input.ptyId)?.flags ?? 0, - readOutput: () => { - const current = readPty() - return { - text: buildTerminalWaitText( - current.tailBuffer, - current.tailPartialLine, - current.preview - ), - lastOutputAt: current.lastOutputAt - } - }, - write: (data) => - this.ptyController?.writeAgentSessionProof?.(readPty().ptyId, data, { - sessionId: input.envelope.sessionId, - spawnToken - }) ?? false + ...proofInput }) threadId = proof.threadId transcriptPath = proof.transcriptPath } - if (!transcriptPath) { - throw new Error('Could not find the rollout for this Codex conversation.') + const confirmedProcess = await readProcess() + if ( + confirmedProcess.pid !== process.pid || + confirmedProcess.processStartTimeMs !== process.processStartTimeMs + ) { + throw new Error('The Codex terminal process changed during conversation proof.') } const owner: StructuredTuiOwner = { terminal: { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts deleted file mode 100644 index b4fba75d3ee..00000000000 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-create.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { - AgentSessionAttachResult, - AgentSessionMutationResult -} from '../../../../shared/agent-session-wire' -import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { - createStructuredAgentSessionOperationId, - showStructuredAgentSessionChoice, - structuredAgentSessionCreateFingerprint -} from '../../../../shared/structured-agent-session-mutation' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { useAppStore } from '@/store' -import { - getActiveRuntimeTarget, - runtimeEnvironmentSupportsCapability -} from '@/runtime/runtime-rpc-client' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' -import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' -import { recordWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' - -const LOCAL_OWNER = 'local-structured-session' - -export function useStructuredAgentSessionCreate(worktreeId: string): { - supported: boolean - creating: boolean - create: () => Promise -} { - const environmentId = useAppStore((state) => - getRuntimeEnvironmentIdForWorktree(state, worktreeId) - ) - const target = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), - [environmentId] - ) - const [supported, setSupported] = useState(false) - const [creating, setCreating] = useState(false) - - useEffect(() => { - let stale = false - setSupported(false) - void (async () => { - const hostCapability = - target.kind === 'local' - ? ((await window.api.runtime.getStatus()).capabilities?.includes( - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ) ?? false) - : await runtimeEnvironmentSupportsCapability( - target.environmentId, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ) - if (!hostCapability) { - return - } - const support = await callStructuredAgentSession<{ supported?: boolean }>( - target, - 'agentSession.createSupport', - { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' } - ) - if (!stale) { - setSupported( - showStructuredAgentSessionChoice({ - hostCapability, - workspaceSupport: support.supported === true, - agent: 'codex' - }) - ) - } - })().catch(() => !stale && setSupported(false)) - return () => { - stale = true - } - }, [target, worktreeId]) - - const create = useCallback(async (): Promise => { - if (!supported || creating) { - return false - } - setCreating(true) - const sessionId = `desktop_${Date.now().toString(36)}${crypto.randomUUID().slice(0, 8)}` - const worktree = toRuntimeWorktreeSelector(worktreeId) - try { - recordWebSessionFocusIntent( - { environmentId: target.kind === 'environment' ? target.environmentId : LOCAL_OWNER }, - worktreeId, - `agent-session:${sessionId}` - ) - const result = await callStructuredAgentSession< - AgentSessionMutationResult - >(target, 'agentSession.create', { - envelope: { - sessionId, - clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), - expectedRuntimeFence: null, - payloadFingerprint: structuredAgentSessionCreateFingerprint({ sessionId, worktree }) - }, - worktree, - agent: 'codex' - }) - if (!result.ok) { - throw new Error(result.refusal.message) - } - return true - } finally { - setCreating(false) - } - }, [creating, supported, target, worktreeId]) - - return { supported, creating, create } -} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5fb4ddd10d8..dd20f51aef3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15833,6 +15833,7 @@ "components": { "native-chat": { "structuredAdoptionFailed": "Could not switch this Codex session to structured chat", + "structuredReturnToTerminalFailed": "Could not return this Codex session to the terminal", "composer": { "imageUnsupported": "Image paste is not supported for this agent.", "send": "Send", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fd3df65b3c3..4c50de2c577 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14325,6 +14325,7 @@ "components": { "native-chat": { "structuredAdoptionFailed": "No se pudo cambiar esta sesión de Codex al chat estructurado", + "structuredReturnToTerminalFailed": "No se pudo devolver esta sesión de Codex al terminal", "composer": { "imageUnsupported": "Este agente no admite pegar imágenes.", "send": "Enviar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 373520ba6d6..d57a3630ee7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14325,6 +14325,7 @@ "components": { "native-chat": { "structuredAdoptionFailed": "この Codex セッションを構造化チャットに切り替えられませんでした", + "structuredReturnToTerminalFailed": "この Codex セッションをターミナルに戻せませんでした", "composer": { "imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。", "send": "送信", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 812531971cb..0641be5527c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14354,6 +14354,7 @@ "components": { "native-chat": { "structuredAdoptionFailed": "이 Codex 세션을 구조화된 채팅으로 전환할 수 없습니다", + "structuredReturnToTerminalFailed": "이 Codex 세션을 터미널로 되돌릴 수 없습니다", "composer": { "imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.", "send": "보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0a1332518cb..97262c2b55a 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14345,6 +14345,7 @@ "components": { "native-chat": { "structuredAdoptionFailed": "无法将此 Codex 会话切换到结构化聊天", + "structuredReturnToTerminalFailed": "无法将此 Codex 会话返回终端", "composer": { "imageUnsupported": "此智能体不支持粘贴图片。", "send": "发送", diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts index 51deb11be28..b2daf1a6d97 100644 --- a/src/renderer/src/i18n/native-chat-locales.test.ts +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -43,6 +43,10 @@ describe('native chat locale copy', () => { expect(localized).not.toBe(english) } expect(search.grok).toBe('grok') + expect(catalog.components['native-chat'].structuredReturnToTerminalFailed.trim()).not.toBe('') + expect(catalog.components['native-chat'].structuredReturnToTerminalFailed).not.toBe( + en.components['native-chat'].structuredReturnToTerminalFailed + ) const composer = catalog.components['native-chat'].composer for (const key of [ 'model', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 8515379d418..8c71ffd2678 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -5,6 +5,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() const mockSetActiveTabType = vi.fn() +const mockSetTabViewMode = vi.fn() const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() @@ -13,6 +14,7 @@ const mockSeedNativeChatLaunchDraft = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() +const mockWaitForAgentReady = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -79,6 +81,7 @@ const store = { closeTab: vi.fn(), queueTabStartupCommand: mockQueueTabStartupCommand, setActiveTabType: mockSetActiveTabType, + setTabViewMode: mockSetTabViewMode, setTabBarOrder: mockSetTabBarOrder, setAgentStatus: mockSetAgentStatus, seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, @@ -112,6 +115,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +vi.mock('@/lib/agent-ready-wait', () => ({ + waitForAgentReady: mockWaitForAgentReady +})) + vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent @@ -171,6 +178,7 @@ describe('launchAgentInNewTab', () => { store.ptyIdsByTabId = {} mockCreateTab.mockReturnValue({ id: 'tab-1' }) mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) }) it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { @@ -212,7 +220,7 @@ describe('launchAgentInNewTab', () => { ) }) - it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { + it('adopts supported Codex launches through the structured chat toggle', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -231,8 +239,7 @@ describe('launchAgentInNewTab', () => { }) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'codex', - viewMode: 'chat' + launchAgent: 'codex' }) expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', @@ -246,6 +253,7 @@ describe('launchAgentInNewTab', () => { text: 'large generated prompt', createdAt: expect.any(Number) }) + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) }) it('opens local Grok submit-after-ready launches in native chat', async () => { @@ -445,8 +453,9 @@ describe('launchAgentInNewTab', () => { 'wt-1', undefined, undefined, - expect.objectContaining({ viewMode: 'chat' }) + expect.not.objectContaining({ viewMode: 'chat' }) ) + await vi.waitFor(() => expect(mockSetTabViewMode).toHaveBeenCalledWith('tab-1', 'chat')) }) it('preserves paired-host draft delivery and supported launch preferences', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d46cb530bd6..0a81d4261a5 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -29,6 +29,8 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' +import { waitForAgentReady } from '@/lib/agent-ready-wait' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -139,6 +141,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI isFollowupPath }) let promptDeliveryResult: Promise<{ delivered: boolean; failureNotified: boolean }> | undefined + let promptDeliveryCompletion: Promise | undefined if (!startupPlan) { return null @@ -175,10 +178,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. + const adoptStructuredChat = + agent === 'codex' && + initialViewModeProps.viewMode === 'chat' && + canUseStructuredNativeChat(store, worktreeId) const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, - ...initialViewModeProps + ...(adoptStructuredChat ? {} : initialViewModeProps) }) seedNativeChatAppliedSessionOptions(tab.id, agent, startupPlan.sessionOptions) if (initialCwd?.trim()) { @@ -236,6 +243,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } return { delivered, failureNotified: !delivered && timeoutNotice.wasNotified() } }) + promptDeliveryCompletion = deliveryPromise if (promptDelivery === 'submit-after-ready') { promptDeliveryResult = deliveryPromise } else { @@ -247,6 +255,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI onPromptDelivered?.() } + if (adoptStructuredChat) { + void (promptDeliveryCompletion ?? Promise.resolve()) + .catch(() => undefined) + .then(async () => { + await waitForAgentReady(tab.id, TUI_AGENT_CONFIG.codex.expectedProcess) + useAppStore.getState().setTabViewMode(tab.id, 'chat') + }) + } + // Why: without setActiveTabType('terminal') a worktree showing an editor keeps rendering it and the new tab stays hidden. store.setActiveTabType('terminal') diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts new file mode 100644 index 00000000000..b0eed7f334a --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -0,0 +1,11 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' + +export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean { + if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { + return false + } + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + return !(projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') +} diff --git a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts index 42c7b31efaf..666ac07eb22 100644 --- a/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { nativeChatRouteForAgent, nativeChatRouteForTerminal, @@ -6,11 +6,86 @@ import { } from './structured-native-chat-toggle' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn() })) + vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: vi.fn() })) -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) +vi.mock('sonner', () => ({ toast: { error: mockToastError } })) + +vi.mock('@/lib/renderer-app-platform', () => ({ + getRendererAppPlatform: () => 'win32' +})) + +function terminalState(input: { + agent?: 'codex' | 'claude' + structuredSessionId?: string + connectionId?: string | null + executionHostId?: `runtime:${string}` + windowsRuntime?: 'windows-host' | 'wsl' +}) { + const agent = input.agent ?? 'codex' + const tab = { + id: `tab-${agent}`, + entityId: `terminal-${agent}`, + worktreeId: 'workspace-1', + contentType: 'terminal' as const, + ...(input.structuredSessionId ? { structuredSessionId: input.structuredSessionId } : {}) + } + const paneKey = `${tab.entityId}:leaf-1` + return { + tab, + state: { + activeRepoId: 'repo-1', + activeWorktreeId: 'workspace-1', + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: + input.windowsRuntime === 'wsl' + ? { kind: 'wsl', distro: 'Ubuntu' } + : { kind: 'windows-host' } + } + ], + repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], + settings: {}, + worktreesByRepo: { + 'repo-1': [ + { + id: 'workspace-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: 'C:\\repo\\worktree', + ...(input.executionHostId ? { hostId: input.executionHostId } : {}) + } + ] + }, + detectedWorktreesByRepo: {}, + unifiedTabsByWorktree: { 'workspace-1': [tab] }, + tabsByWorktree: { + 'workspace-1': [{ id: tab.entityId, launchAgent: agent }] + }, + terminalLayoutsByTabId: { + [tab.entityId]: { + activeLeafId: 'leaf-1', + ptyIdsByLeafId: { 'leaf-1': `pty-${agent}` } + } + }, + agentStatusByPaneKey: { + [paneKey]: { + agentType: agent, + providerSession: { id: `thread-${agent}` } + } + } + } + } +} + +beforeEach(() => { + vi.mocked(callStructuredAgentSession).mockReset() + mockToastError.mockReset() +}) describe('native chat routing', () => { it.each([ @@ -37,50 +112,149 @@ describe('native chat routing', () => { expect(nativeChatRouteForTerminal({ agent: 'codex', mode: 'terminal' })).toBe('bridge') }) - it('retains the durable binding when the first handoff attempt fails', async () => { - const tab = { - id: 'tab-1', - entityId: 'terminal-1', - worktreeId: 'workspace-1', - contentType: 'terminal' as const - } - const state = { - unifiedTabsByWorktree: { 'workspace-1': [tab] }, - tabsByWorktree: { 'workspace-1': [{ id: 'terminal-1', launchAgent: 'codex' }] }, - terminalLayoutsByTabId: { - 'terminal-1': { - activeLeafId: 'leaf-1', - ptyIdsByLeafId: { 'leaf-1': 'pty-1' } - } - }, - agentStatusByPaneKey: { - 'terminal-1:leaf-1': { - agentType: 'codex', - providerSession: { id: 'thread-1' } - } - } - } - vi.mocked(callStructuredAgentSession) - .mockResolvedValueOnce({ ok: true, fence: 1 }) - .mockRejectedValueOnce(new Error('method_not_found')) + it.each([ + ['SSH', { connectionId: 'ssh-1' }], + ['paired runtime', { executionHostId: 'runtime:paired-1' as const }], + ['WSL', { windowsRuntime: 'wsl' as const }] + ])('keeps %s Codex panes on the transcript bridge', async (_label, overrides) => { + const { tab, state } = terminalState(overrides) const patch = vi.fn() - const result = await setTerminalNativeChatMode({ - getState: () => state as never, - patch, - tabId: tab.id, - mode: 'chat' - }) + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('bridge') - expect(result).toBe('ignored') - expect(patch).toHaveBeenCalledTimes(1) - expect(patch).toHaveBeenCalledWith(tab.id, { - structuredSessionId: 'codex_thread-1' + expect(callStructuredAgentSession).not.toHaveBeenCalled() + expect(patch).toHaveBeenCalledWith(tab.id, { viewMode: 'chat' }) + }) + + it('retries a failed initial handoff with its original operation id', async () => { + const { tab, state } = terminalState({}) + const patch = vi.fn((tabId: string, next: Record) => { + if (tabId === tab.id) { + Object.assign(tab, next) + } + }) + let originalOperationId = '' + let startSent = false + let retrySent = false + vi.mocked(callStructuredAgentSession).mockImplementation( + async (_target, method, params: unknown) => { + const request = params as { + action?: string + envelope?: { clientOperationId?: string } + } + if (method === 'agentSession.adoptTerminal') { + return { ok: true, fence: 1 } as never + } + if (method === 'agentSession.history') { + return { ok: true, page: { fence: 2 } } as never + } + if (method === 'agentSession.handoff') { + if (request.action === 'start') { + originalOperationId = request.envelope?.clientOperationId ?? '' + startSent = true + } else { + expect(request).toMatchObject({ + action: 'retry', + envelope: { clientOperationId: originalOperationId } + }) + retrySent = true + } + return { ok: true } as never + } + if (method === 'agentSession.handoffStatus') { + return ( + retrySent + ? { owner: 'native', direction: null, phase: 'idle' } + : !startSent + ? { owner: 'tui', direction: null, phase: 'idle' } + : { + owner: 'tui', + direction: 'to-native', + phase: 'failed', + operationId: originalOperationId, + error: { message: 'native proof unavailable', recoverableOwner: 'tui' } + } + ) as never + } + throw new Error(`Unexpected method: ${method}`) + } + ) + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('ignored') + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('structured') + + expect(originalOperationId).not.toBe('') + expect(tab).toMatchObject({ structuredSessionId: 'codex_thread-codex' }) + expect(patch).toHaveBeenLastCalledWith(tab.id, { + structuredSessionId: 'codex_thread-codex', + viewMode: 'chat' }) }) + it('clears a stale Codex binding before routing another agent to the bridge', async () => { + const { tab, state } = terminalState({ + agent: 'claude', + structuredSessionId: 'codex_old-thread' + }) + const patch = vi.fn() + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch, + tabId: tab.id, + mode: 'chat' + }) + ).resolves.toBe('bridge') + + expect(callStructuredAgentSession).not.toHaveBeenCalled() + expect(patch).toHaveBeenCalledWith(tab.id, { + viewMode: 'chat', + structuredSessionId: undefined + }) + }) + + it('names the terminal direction when returning ownership fails', async () => { + const { tab, state } = terminalState({ structuredSessionId: 'codex_thread-codex' }) + vi.mocked(callStructuredAgentSession).mockRejectedValueOnce(new Error('history unavailable')) + + await expect( + setTerminalNativeChatMode({ + getState: () => state as never, + patch: vi.fn(), + tabId: tab.id, + mode: 'terminal' + }) + ).resolves.toBe('ignored') + + expect(mockToastError).toHaveBeenCalledWith( + 'Could not return this Codex session to the terminal', + { description: 'history unavailable' } + ) + }) + it('adopts from trusted foreground Codex evidence before hooks publish a thread', async () => { - vi.mocked(callStructuredAgentSession).mockReset() const tab = { id: 'tab-foreground', entityId: 'terminal-foreground', diff --git a/src/renderer/src/store/slices/structured-native-chat-toggle.ts b/src/renderer/src/store/slices/structured-native-chat-toggle.ts index 31b20f18fb1..828d3ed01a5 100644 --- a/src/renderer/src/store/slices/structured-native-chat-toggle.ts +++ b/src/renderer/src/store/slices/structured-native-chat-toggle.ts @@ -14,6 +14,7 @@ import { import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { translate } from '@/i18n/i18n' +import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' import type { AppState } from '../types' export type NativeChatRoute = 'structured' | 'bridge' @@ -26,9 +27,13 @@ export function nativeChatRouteForTerminal(input: { agent: AgentType | null structuredSessionId?: string mode: 'terminal' | 'chat' + structuredAvailable?: boolean }): NativeChatRoute { + if (input.structuredAvailable === false) { + return 'bridge' + } if (input.structuredSessionId) { - return 'structured' + return input.agent === null || input.agent === 'codex' ? 'structured' : 'bridge' } return input.mode === 'chat' ? nativeChatRouteForAgent(input.agent) : 'bridge' } @@ -93,7 +98,7 @@ async function waitForOwner( { sessionId } ) if (status.phase === 'failed') { - throw new Error(status.error?.message ?? 'Codex ownership transfer failed.') + return status } if (status.owner === owner && status.phase === 'idle') { return status @@ -106,16 +111,18 @@ async function waitForOwner( async function requestHandoff( sessionId: string, direction: 'to-native' | 'to-tui', - fence: number + fence: number, + clientOperationId: string, + action: 'start' | 'retry' ): Promise { - const fields = { direction, mode: 'after-turn' as const, action: 'start' as const } + const fields = { direction, mode: 'after-turn' as const, action } const result = await callStructuredAgentSession>( { kind: 'local' }, 'agentSession.handoff', { envelope: { sessionId, - clientOperationId: operationId(), + clientOperationId, expectedRuntimeFence: fence, payloadFingerprint: structuredAgentSessionPayloadFingerprint({ method: 'agentSession.requestHandoff', @@ -133,6 +140,23 @@ async function requestHandoff( const pendingTabs = new Set() +async function recoverableHandoffOperation( + sessionId: string, + direction: 'to-native' | 'to-tui' +): Promise { + const status = await callStructuredAgentSession( + { kind: 'local' }, + 'agentSession.handoffStatus', + { sessionId } + ) + return status.phase === 'failed' && + status.direction === direction && + status.operationId && + status.error?.recoverableOwner !== 'none' + ? status.operationId + : null +} + export async function setTerminalNativeChatMode(input: { getState: () => AppState patch: (tabId: string, patch: Partial) => void @@ -149,14 +173,21 @@ export async function setTerminalNativeChatMode(input: { return 'ignored' } const facts = activeTerminalFacts(input.getState(), tab) + const structuredAvailable = canUseStructuredNativeChat(input.getState(), tab.worktreeId) if ( nativeChatRouteForTerminal({ agent: facts.agent, structuredSessionId: tab.structuredSessionId, - mode: input.mode + mode: input.mode, + structuredAvailable }) === 'bridge' ) { - input.patch(tab.id, { viewMode: input.mode }) + input.patch(tab.id, { + viewMode: input.mode, + ...(tab.structuredSessionId && facts.agent !== 'codex' + ? { structuredSessionId: undefined } + : {}) + }) return 'bridge' } pendingTabs.add(tab.id) @@ -203,15 +234,30 @@ export async function setTerminalNativeChatMode(input: { fence = await currentFence(sessionId) } const direction = input.mode === 'chat' ? 'to-native' : 'to-tui' - await requestHandoff(sessionId, direction, fence) - await waitForOwner(sessionId, input.mode === 'chat' ? 'native' : 'tui') + const recoverableOperationId = await recoverableHandoffOperation(sessionId, direction) + const handoffOperationId = recoverableOperationId ?? operationId() + await requestHandoff( + sessionId, + direction, + fence, + handoffOperationId, + recoverableOperationId ? 'retry' : 'start' + ) + const status = await waitForOwner(sessionId, input.mode === 'chat' ? 'native' : 'tui') + if (status.phase === 'failed') { + throw new Error(status.error?.message ?? 'Codex ownership transfer failed.') + } input.patch(tab.id, { structuredSessionId: sessionId, viewMode: input.mode }) return 'structured' } catch (error) { toast.error( translate( - 'components.native-chat.structuredAdoptionFailed', - 'Could not switch this Codex session to structured chat' + input.mode === 'chat' + ? 'components.native-chat.structuredAdoptionFailed' + : 'components.native-chat.structuredReturnToTerminalFailed', + input.mode === 'chat' + ? 'Could not switch this Codex session to structured chat' + : 'Could not return this Codex session to the terminal' ), { description: error instanceof Error ? error.message : String(error) } )