fix structured Codex TUI adoption

This commit is contained in:
Brennan Benson
2026-08-18 20:14:30 -07:00
parent 6b5e0b6c45
commit 0c74172fcd
14 changed files with 428 additions and 209 deletions
@@ -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(
+44 -37
View File
@@ -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: {
@@ -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<boolean>
} {
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<boolean> => {
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<AgentSessionAttachResult>
>(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 }
}
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -14325,6 +14325,7 @@
"components": {
"native-chat": {
"structuredAdoptionFailed": "この Codex セッションを構造化チャットに切り替えられませんでした",
"structuredReturnToTerminalFailed": "この Codex セッションをターミナルに戻せませんでした",
"composer": {
"imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。",
"send": "送信",
+1
View File
@@ -14354,6 +14354,7 @@
"components": {
"native-chat": {
"structuredAdoptionFailed": "이 Codex 세션을 구조화된 채팅으로 전환할 수 없습니다",
"structuredReturnToTerminalFailed": "이 Codex 세션을 터미널로 되돌릴 수 없습니다",
"composer": {
"imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.",
"send": "보내기",
+1
View File
@@ -14345,6 +14345,7 @@
"components": {
"native-chat": {
"structuredAdoptionFailed": "无法将此 Codex 会话切换到结构化聊天",
"structuredReturnToTerminalFailed": "无法将此 Codex 会话返回终端",
"composer": {
"imageUnsupported": "此智能体不支持粘贴图片。",
"send": "发送",
@@ -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',
@@ -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 () => {
@@ -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<unknown> | 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')
@@ -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')
}
@@ -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<string, unknown>) => {
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',
@@ -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<void> {
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<AgentSessionMutationResult<unknown>>(
{ 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<string>()
async function recoverableHandoffOperation(
sessionId: string,
direction: 'to-native' | 'to-tui'
): Promise<string | null> {
const status = await callStructuredAgentSession<AgentSessionHandoffStatus>(
{ 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<Tab>) => 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) }
)