fix structured session TUI handoff recovery

This commit is contained in:
Brennan Benson
2026-08-13 11:22:13 -07:00
parent 114eee22dd
commit ecdd5c8dd9
18 changed files with 370 additions and 82 deletions
@@ -0,0 +1,35 @@
import { randomUUID } from 'node:crypto'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation'
import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status'
export async function recoverDeadTuiHandoffStatus(input: {
store: AgentSessionRecordStore
now: () => number
record: AgentSessionRecord
expectedFence: number
probe: AgentSessionOwnerProbe
}): Promise<AgentSessionHandoffStatus | null> {
const { expectedFence, now, probe, record, store } = input
if (
record.lease.runtimeFence !== expectedFence ||
record.lease.runtimeKind !== 'tui' ||
record.lease.handoffStage !== null ||
record.lease.claimStatus !== 'live' ||
record.lease.ownerProcess === null
) {
return null
}
const recovered = await recoverStoredDeadTuiOwnerForHandoff(store, {
sessionId: record.sessionId,
expectedFence,
operationId: createStructuredAgentSessionOperationId(randomUUID, now()),
probe,
now: now()
})
return idleStructuredHandoffStatus(recovered)
}
@@ -29,15 +29,18 @@ import type {
const CALLER = { callerKey: 'client-1' }
const DEFAULT_MODEL = 'gpt-default'
const PICKED_MODEL = 'gpt-picked'
const PICKED_EFFORT = 'medium'
let root: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let acquire: Mock<StructuredAgentSessionAdapter['acquire']>
let activeModel: string
let activeEffort: string | null
let transcriptPath: string
let optionFailure: Error | null
const dispatchedModels: string[] = []
const launchedOptions: (Readonly<Record<string, string>> | undefined)[] = []
function envelope(method: string, fields: Record<string, unknown>): AgentSessionMutationEnvelope {
return {
@@ -80,7 +83,10 @@ function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner {
function handoffTransport(): StructuredAgentSessionHandoffTransport {
return {
hostLabel: 'Test host',
launchTui: async ({ fence, spawnToken }) => tuiOwner(fence, spawnToken),
launchTui: async ({ record, fence, spawnToken }) => {
launchedOptions.push(record.options)
return tuiOwner(fence, spawnToken)
},
reproveTuiOwner: async ({ owner }) => owner,
recoverTuiOwner: async (record) =>
tuiOwner(
@@ -97,6 +103,7 @@ function handoffTransport(): StructuredAgentSessionHandoffTransport {
function adapter(): StructuredAgentSessionAdapter {
acquire = vi.fn(async ({ fence, spawnToken, options }) => {
activeModel = options?.model ?? DEFAULT_MODEL
activeEffort = options?.effort ?? null
return {
process: {
hostId: 'local',
@@ -124,16 +131,26 @@ function adapter(): StructuredAgentSessionAdapter {
}),
cancelTurn: vi.fn(async () => ({ cancelled: true })),
answerPrompt: vi.fn(async () => undefined),
setOption: vi.fn(async ({ value }) => {
setOption: vi.fn(async ({ key, value }) => {
if (optionFailure) {
const error = optionFailure
optionFailure = null
throw error
}
activeModel = value
return { model: value }
if (key === 'model') {
activeModel = value
} else if (key === 'effort') {
activeEffort = value
}
return {
model: activeModel,
...(activeEffort ? { effort: activeEffort } : {})
}
}),
readOptions: vi.fn(async () => ({ current: { model: activeModel }, models: [] })),
readOptions: vi.fn(async () => ({
current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) },
models: []
})),
closeSession: vi.fn(async () => {
activeModel = DEFAULT_MODEL
})
@@ -144,8 +161,10 @@ beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-'))
resetHostTestOperationIds()
activeModel = DEFAULT_MODEL
activeEffort = null
optionFailure = null
dispatchedModels.length = 0
launchedOptions.length = 0
const accountHome = join(root, 'codex-home')
const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12')
transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`)
@@ -217,6 +236,18 @@ describe('structured session handoff options', () => {
).toMatchObject({ ok: true })
expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL })
const effortFields = { key: 'effort', value: PICKED_EFFORT }
expect(
await host.setOption(CALLER, {
envelope: envelope('agentSession.setOption', effortFields),
...effortFields
})
).toMatchObject({ ok: true })
expect(store.getRecord(SESSION)?.options).toEqual({
model: PICKED_MODEL,
effort: PICKED_EFFORT
})
expect(await host.requestHandoff(CALLER, handoff('to-tui'))).toMatchObject({ ok: true })
await vi.waitFor(async () =>
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'tui' })
@@ -226,8 +257,15 @@ describe('structured session handoff options', () => {
expect(await host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' })
)
expect(acquire.mock.calls[1]?.[0].options).toEqual({ model: PICKED_MODEL })
expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL })
expect(launchedOptions).toEqual([{ model: PICKED_MODEL, effort: PICKED_EFFORT }])
expect(acquire.mock.calls[1]?.[0].options).toEqual({
model: PICKED_MODEL,
effort: PICKED_EFFORT
})
expect(store.getRecord(SESSION)?.options).toEqual({
model: PICKED_MODEL,
effort: PICKED_EFFORT
})
const body = hostTestMessage('use the selected model')
expect(
await host.send(CALLER, {
@@ -302,7 +302,7 @@ export class StructuredAgentSessionHandoffCoordinator {
)
}
private setStatus(sessionId: string, status: AgentSessionHandoffStatus): void {
setStatus(sessionId: string, status: AgentSessionHandoffStatus): void {
this.statuses.set(sessionId, status)
this.deps.publish(sessionId, status)
}
@@ -1,4 +1,5 @@
import { join } from 'node:path'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import'
import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
@@ -8,6 +9,7 @@ import type { DeferredStructuredAgentSessionEventSink } from './structured-agent
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host'
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui-recovery'
import { readNativeHandoffSessionOptions } from './structured-agent-session-handoff-options'
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup'
@@ -23,6 +25,11 @@ type HostHandoffAccess = {
export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator & {
stopTuiHistoryCatchup: () => void
recoverDeadTuiOwner: (
sessionId: string,
expectedFence: number,
probe: AgentSessionOwnerProbe
) => Promise<void>
}
export async function refreshRecoverableStructuredHandoffStatus(
@@ -81,7 +88,29 @@ export function createStructuredAgentSessionHostHandoff(
schedule: host.serialize,
now: host.now
})
return Object.assign(coordinator, { stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll() })
return Object.assign(coordinator, {
stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll(),
recoverDeadTuiOwner: async (
sessionId: string,
expectedFence: number,
probe: AgentSessionOwnerProbe
) => {
const record = deps.store.getRecord(sessionId)
if (!record) {
return
}
const status = await recoverDeadTuiHandoffStatus({
store: deps.store,
now: host.now,
record,
expectedFence,
probe
})
if (status) {
coordinator.setStatus(sessionId, status)
}
}
})
}
async function importTuiHistory(
@@ -13,13 +13,15 @@ export class StructuredAgentSessionHostRuntimeState {
constructor(
private readonly deps: StructuredAgentSessionHostDeps,
onLeaseRenewed?: (record: AgentSessionRecord) => Promise<void>
onLeaseRenewed?: (record: AgentSessionRecord) => Promise<void>,
onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise<void>
) {
this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({
store: deps.store,
probe: (record) => this.probeRecord(record),
now: () => deps.now?.() ?? Date.now(),
...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}),
...(onDeadTuiOwner ? { onDeadTuiOwner } : {}),
onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error })
})
}
@@ -68,8 +68,15 @@ export class StructuredAgentSessionHost {
private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate()
constructor(readonly deps: StructuredAgentSessionHostDeps) {
this.runtimeState = new StructuredAgentSessionHostRuntimeState(deps, (record) =>
this.restoreRenewedHandoff(record.sessionId)
this.runtimeState = new StructuredAgentSessionHostRuntimeState(
deps,
(record) => this.restoreRenewedHandoff(record.sessionId),
(record, probe) =>
this.sessions.has(record.sessionId)
? this.serialize(record.sessionId, () =>
this.handoffs.recoverDeadTuiOwner(record.sessionId, record.lease.runtimeFence, probe)
)
: Promise.resolve()
)
this.reconcileLeases = createRestartReconciler({
store: deps.store,
@@ -132,4 +132,30 @@ describe('structured agent-session lease renewal', () => {
error: expect.any(Error)
})
})
it('routes a proven dead TUI owner into handoff recovery', async () => {
const store = await liveStore()
await store.transitionHandoff('session-renewal', (record) => ({
...record,
lease: { ...record.lease, runtimeKind: 'tui' }
}))
const onDeadTuiOwner = vi.fn(async () => undefined)
const onError = vi.fn()
const renewer = new StructuredAgentSessionLeaseRenewer({
store,
probe: async () => ({ outcome: 'pid-absent' }),
now: () => NOW + 10_000,
onDeadTuiOwner,
onError
})
await renewer.renewNow()
expect(onDeadTuiOwner).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'session-renewal' }),
{ outcome: 'pid-absent' }
)
expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW)
expect(onError).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,7 @@
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import {
isProvenDeadProbe,
type AgentSessionOwnerProbe
} from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
AGENT_SESSION_LEASE_TTL_MS,
@@ -17,6 +20,7 @@ export class StructuredAgentSessionLeaseRenewer {
probe: (record: AgentSessionRecord) => Promise<AgentSessionOwnerProbe>
now: () => number
onRenewed?: (record: AgentSessionRecord) => Promise<void>
onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise<void>
onError?: (input: { sessionId: string; error: unknown }) => void
intervalMs?: number
}
@@ -61,10 +65,19 @@ export class StructuredAgentSessionLeaseRenewer {
private async renewRecord(record: AgentSessionRecord): Promise<void> {
try {
const probe = await this.input.probe(record)
if (
record.lease.runtimeKind === 'tui' &&
isProvenDeadProbe(probe) &&
this.input.onDeadTuiOwner
) {
await this.input.onDeadTuiOwner(record, probe)
return
}
const renewed = await this.input.store.renewLease({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
childProbe: await this.input.probe(record),
childProbe: probe,
now: this.input.now()
})
await this.input.onRenewed?.(renewed)
@@ -3,9 +3,35 @@ import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { recoverDeadTuiOwnerForHandoff } from './agent-session-handoff-lease-transitions'
import { applyAgentSessionRestartAdjudication } from './agent-session-lease-transitions'
describe('agent session handoff restart transitions', () => {
it('turns a proven dead TUI owner into one durable retry owner', () => {
const operationId = '1800000000000-00000000000000000000000000000001'
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'tui', runtimeFence: 3 })
)
const next = recoverDeadTuiOwnerForHandoff({
record,
expectedFence: 3,
operationId,
probe: { outcome: 'pid-absent' },
now: 1_800_000_001_000
})
expect(next.lease).toMatchObject({
runtimeKind: 'tui',
runtimeFence: 4,
handoffStage: 'old-owner-stopped',
handoffOperationId: operationId,
claimStatus: 'released',
ownerProcess: null,
deathEvidence: { kind: 'pid-absent' }
})
})
it('preserves the stopped owner and operation for durable retry', () => {
const handoffOperationId = '1800000000000-00000000000000000000000000000001'
const record = agentSessionRecordFixture(
@@ -1,8 +1,39 @@
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
import type {
AgentSessionOwnerRuntimeKind,
AgentSessionRecord
} from '../../shared/agent-session-record'
import { assertFence, reserveAgentSessionOwner, withLease } from './agent-session-lease-transitions'
import {
assertFence,
evictAgentSessionOwner,
reserveAgentSessionOwner,
withLease
} from './agent-session-lease-transitions'
export function recoverDeadTuiOwnerForHandoff(args: {
record: AgentSessionRecord
expectedFence: number
operationId: string
probe: AgentSessionOwnerProbe
now: number
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
if (
record.lease.runtimeKind !== 'tui' ||
record.lease.handoffStage !== null ||
record.lease.claimStatus !== 'live' ||
record.lease.ownerProcess === null
) {
throw new Error('agent_session_ownership_unknown')
}
const evicted = evictAgentSessionOwner(args)
return withLease(evicted, {
...evicted.lease,
handoffStage: 'old-owner-stopped',
handoffOperationId: args.operationId
})
}
export function stopAgentSessionOwnerForHandoff(args: {
record: AgentSessionRecord
@@ -1,6 +1,8 @@
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
import type { AgentSessionHandoffStage } from '../../shared/agent-session-record'
import {
abandonAgentSessionHandoffAttempt,
recoverDeadTuiOwnerForHandoff,
reserveAgentSessionHandoffOwner,
rollbackAgentSessionHandoffPreparation,
stopAgentSessionOwnerForHandoff,
@@ -27,6 +29,21 @@ export function setStoredAgentSessionHandoffStage(
)
}
export function recoverStoredDeadTuiOwnerForHandoff(
store: AgentSessionRecordStore,
args: {
sessionId: string
expectedFence: number
operationId: string
probe: AgentSessionOwnerProbe
now: number
}
) {
return store.transitionHandoff(args.sessionId, (record) =>
recoverDeadTuiOwnerForHandoff({ ...args, record })
)
}
export function stopStoredAgentSessionOwnerForHandoff(
store: AgentSessionRecordStore,
args: { sessionId: string; expectedFence: number; operationId: string; now: number }
@@ -180,7 +180,9 @@ describe('structured TUI launch tab binding', () => {
getSettings: () => ({
disabledTuiAgents: [],
agentCmdOverrides: {},
agentDefaultArgs: {},
agentDefaultArgs: {
codex: '-m gpt-5.6-sol -c model_reasoning_effort=high'
},
agentDefaultEnv: {}
})
} as never,
@@ -190,8 +192,9 @@ describe('structured TUI launch tab binding', () => {
}
)
runtime.setNotifier(notifier(revealTerminalSession) as never)
const spawn = vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 })
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-structured', pid: 4242 }),
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
@@ -264,6 +267,7 @@ describe('structured TUI launch tab binding', () => {
sessionId: 'session-1',
location: { workspaceId: WORKTREE_ID, executionHostId: 'local' },
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
options: { model: 'gpt-5.6-terra', effort: 'medium' },
providerHandleChain: [
{ handle: { provider: 'codex', threadId: 'thread-1' }, observedAt: 1 }
]
@@ -289,6 +293,11 @@ describe('structured TUI launch tab binding', () => {
expect(waitForStructuredTuiProof.mock.invocationCallOrder[0]).toBeLessThan(
revealTerminalSession.mock.invocationCallOrder[0]!
)
const launchCommand = spawn.mock.calls[0]?.[0]?.command
expect(launchCommand).toContain("'-m' 'gpt-5.6-terra'")
expect(launchCommand).toContain("'-c' 'model_reasoning_effort=medium'")
expect(launchCommand).not.toContain('gpt-5.6-sol')
expect(launchCommand).not.toContain('model_reasoning_effort=high')
Object.assign(internal.handles.get(owner.terminal.handle)!, {
rendererGraphEpoch: -1,
+2
View File
@@ -9133,6 +9133,7 @@ export class OrcaRuntimeService {
worktree: `id:${record.location.workspaceId}`,
agent: 'codex',
providerSession: { key: 'session_id', id: head.handle.threadId },
...(record.options ? { launchPreferences: record.options } : {}),
presentation: 'background'
},
{},
@@ -25904,6 +25905,7 @@ export class OrcaRuntimeService {
},
ompResumeFilePath: request.ompResumeFilePath,
sessionOptions: this.toAgentSessionOptions(request.launchPreferences),
sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences),
platform,
shell,
isRemote
@@ -30,4 +30,29 @@ describe('StructuredAgentSessionHandoffChrome', () => {
expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn')
})
it('offers one Retry action for a recoverable dead TUI owner', () => {
const onRequest = vi.fn()
render(
<StructuredAgentSessionHandoffChrome
status={{
owner: 'tui',
direction: 'to-native',
phase: 'failed',
stage: 'old-owner-stopped',
operationId: '1800000000000-00000000000000000000000000000001',
error: {
message: "Couldn't resume chat — the agent terminal still owns this session",
recoverableOwner: 'tui'
}
}}
isWorking={false}
onRequest={onRequest}
/>
)
expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry')
})
})
@@ -95,7 +95,7 @@ export function StructuredAgentSessionHandoffChrome({
{translate('components.native-chat.handoff.cancel', 'Cancel')}
</Button>
</>
) : owner === 'native' && !switching ? (
) : owner === 'native' && phase === 'idle' ? (
isWorking ? (
<>
<Button
@@ -132,7 +132,7 @@ export function StructuredAgentSessionHandoffChrome({
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
</Button>
)
) : owner === 'tui' && !switching ? (
) : owner === 'tui' && phase === 'idle' ? (
<Button
type="button"
variant="ghost"
+77
View File
@@ -0,0 +1,77 @@
import {
getAgentResumeArgv,
type AgentProviderSessionMetadata,
type ResumableTuiAgent
} from './agent-session-resume'
import type { SessionOptionValue } from './native-chat-session-options'
import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config'
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
import type { AgentStartupPlan } from './tui-agent-startup'
import {
quoteStartupArg,
resolveStartupShell,
type AgentStartupShell
} from './tui-agent-startup-shell'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { TuiAgent } from './types'
export function buildAgentResumeStartupPlan(args: {
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
shell?: AgentStartupShell
agentArgs?: string | null
agentEnv?: Record<string, string> | null
agentCommand?: string | null
ompResumeFilePath?: string | null
sessionOptions?: Record<string, SessionOptionValue>
sessionOptionsOverrideAgentArgs?: boolean
isRemote?: boolean
}): AgentStartupPlan | null {
const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
if (!argv) {
return null
}
const shell = resolveStartupShell(args.platform, args.shell)
const resolvedAgentCommand = args.agentCommand?.trim()
const baseCommand = resolvedAgentCommand
? ({
ok: true,
command: resolvedAgentCommand,
commandWithoutSessionOptions: resolvedAgentCommand,
appliedSessionOptions: {}
} as const)
: resolveAgentLaunchCommand({
agent: args.agent,
cmdOverrides: args.cmdOverrides,
platform: args.platform,
shell,
agentArgs: args.agentArgs,
sessionOptions: args.sessionOptions,
sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs,
isRemote: args.isRemote
})
if (!baseCommand.ok) {
return null
}
const launchConfig = buildSleepingAgentLaunchConfig({
...args,
agentCommand: baseCommand.commandWithoutSessionOptions
})
const resumeArgs = argv
.slice(1)
.map((arg) => quoteStartupArg(arg, shell))
.join(' ')
const launchCommand = resumeArgs ? `${baseCommand.command} ${resumeArgs}` : baseCommand.command
const applied = baseCommand.appliedSessionOptions
return {
agent: args.agent,
launchCommand,
expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess,
followupPrompt: null,
launchConfig,
...(Object.keys(applied).length > 0 ? { sessionOptions: { ...applied } } : {}),
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
@@ -140,15 +140,22 @@ describe('tui agent startup session options', () => {
expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' })
})
it('never injects session options into resume commands', () => {
it('applies explicit session options to resume commands', () => {
const plan = buildAgentResumeStartupPlan({
agent: 'codex',
providerSession: { key: 'session_id', id: 'thread-1' },
cmdOverrides: {},
platform: 'linux',
sessionOptions: { model: 'gpt-5.5', effort: 'high' }
agentArgs: '-m gpt-5.6-sol -c model_reasoning_effort=medium',
sessionOptions: { model: 'gpt-5.5', effort: 'high' },
sessionOptionsOverrideAgentArgs: true
})
expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'")
expect(plan?.sessionOptions).toBeUndefined()
expect(plan?.launchCommand).toBe(
"codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high' 'resume' 'thread-1'"
)
expect(plan?.launchConfig.agentCommand).toBe(
"codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium'"
)
expect(plan?.sessionOptions).toEqual({ model: 'gpt-5.5', effort: 'high' })
})
})
+3 -59
View File
@@ -1,10 +1,5 @@
import { isShellProcess } from './agent-detection'
import {
getAgentResumeArgv,
type AgentProviderSessionMetadata,
type ResumableTuiAgent,
type SleepingAgentLaunchConfig
} from './agent-session-resume'
import type { SleepingAgentLaunchConfig } from './agent-session-resume'
import {
clearEnvCommand,
commandSeparator,
@@ -21,6 +16,8 @@ import type { TuiAgent } from './types'
import type { SessionOptionValue } from './native-chat-session-options'
import { resolveAgentLaunchCommand } from './tui-agent-launch-command'
export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup'
export type AgentStartupPlan = {
agent: TuiAgent
launchCommand: string
@@ -184,59 +181,6 @@ export function buildAgentStartupPlan(args: {
}
}
export function buildAgentResumeStartupPlan(args: {
agent: ResumableTuiAgent
providerSession: AgentProviderSessionMetadata
cmdOverrides: Partial<Record<TuiAgent, string>>
platform: NodeJS.Platform
shell?: AgentStartupShell
agentArgs?: string | null
agentEnv?: Record<string, string> | null
agentCommand?: string | null
ompResumeFilePath?: string | null
sessionOptions?: Record<string, SessionOptionValue>
/** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */
isRemote?: boolean
}): AgentStartupPlan | null {
const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath)
if (!argv) {
return null
}
const shell = resolveStartupShell(args.platform, args.shell)
const config = TUI_AGENT_CONFIG[args.agent]
const resolvedAgentCommand = args.agentCommand?.trim()
const baseCommand = resolvedAgentCommand
? ({ ok: true, command: resolvedAgentCommand } as const)
: resolveAgentLaunchCommand({
agent: args.agent,
cmdOverrides: args.cmdOverrides,
platform: args.platform,
shell,
agentArgs: args.agentArgs,
isRemote: args.isRemote
})
if (!baseCommand.ok) {
return null
}
const launchConfig = buildSleepingAgentLaunchConfig({
...args,
agentCommand: baseCommand.command
})
const resumeArgs = argv
.slice(1)
.map((arg) => quoteStartupArg(arg, shell))
.join(' ')
const launchCommand = resumeArgs ? `${baseCommand.command} ${resumeArgs}` : baseCommand.command
return {
agent: args.agent,
launchCommand,
expectedProcess: config.expectedProcess,
followupPrompt: null,
launchConfig,
...(args.agentEnv ? { env: { ...args.agentEnv } } : {})
}
}
export type AgentDraftLaunchPlan = {
agent: TuiAgent
launchCommand: string