mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
fix structured Codex TUI adoption
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -14325,6 +14325,7 @@
|
||||
"components": {
|
||||
"native-chat": {
|
||||
"structuredAdoptionFailed": "この Codex セッションを構造化チャットに切り替えられませんでした",
|
||||
"structuredReturnToTerminalFailed": "この Codex セッションをターミナルに戻せませんでした",
|
||||
"composer": {
|
||||
"imageUnsupported": "この Agent では画像の貼り付けはサポートされていません。",
|
||||
"send": "送信",
|
||||
|
||||
@@ -14354,6 +14354,7 @@
|
||||
"components": {
|
||||
"native-chat": {
|
||||
"structuredAdoptionFailed": "이 Codex 세션을 구조화된 채팅으로 전환할 수 없습니다",
|
||||
"structuredReturnToTerminalFailed": "이 Codex 세션을 터미널로 되돌릴 수 없습니다",
|
||||
"composer": {
|
||||
"imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.",
|
||||
"send": "보내기",
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user