Codex restarts keep the configured command before a session is recorded (#33629)

* fix(codex): honor configured command on unrecorded restarts

* test: update account restart startup consumers
This commit is contained in:
Brennan Benson
2026-10-26 18:45:52 -07:00
committed by GitHub
parent 028f609b5a
commit 7f846fd4f9
7 changed files with 145 additions and 65 deletions
@@ -4,6 +4,7 @@ import { registerRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-regis
import { provideLayoutFrameTerminalPanes } from '@/runtime/layout-frame-terminal-panes'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import type { PublishedTab } from '../../../../shared/workspace-layout/workspace-layout-published'
import { getDefaultSettings } from '../../../../shared/constants'
import { awaitsCodexRestartAnswer, blocksCodexPaneInput } from '../codex-restart-notice-state'
import { ptyDataHandlers } from './pty-dispatcher'
import { deliverPtyExitToHandlers } from './pty-exit-delivery'
@@ -58,7 +59,7 @@ function seedQueuedRestart(
): void {
const leafId = opts.leafId === undefined ? LEAF_ID : opts.leafId
useAppStore.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
settings: getDefaultSettings('/home/dev'),
worktreesByRepo: {
repo1: [{ id: 'wt1', path: '/Users/dev/code/orca' }]
} as never,
@@ -536,6 +537,10 @@ describe('codex detached pane restart executor', () => {
it('kills now and defers the Codex respawn to mount when the layout leaf is unknown', async () => {
seedQueuedRestart({ leafId: null })
const command = String.raw`& 'C:\Program Files\Codex\codex.exe'`
useAppStore.setState({
settings: { ...getDefaultSettings('/home/dev'), agentCmdOverrides: { codex: command } }
})
await sweepUnclaimedCodexPaneRestarts()
@@ -545,7 +550,7 @@ describe('codex detached pane restart executor', () => {
expect(state.ptyIdsByTabId['tab-1']).toEqual([])
expect(state.pendingStartupByTabId['tab-1']).toEqual(
expect.objectContaining({
command: 'codex',
command,
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
})
@@ -5,8 +5,10 @@ import type { AppState } from '@/store'
import { useAppStore } from '@/store'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
import { hasRegisteredRuntimeTerminalTab } from '@/runtime/mounted-terminal-tab-registry'
import { buildPaneAccountRestartStartup } from '@/lib/agent-account-restart-startup'
import { CODEX_ACCOUNT_RESTART_STARTUP } from '@/lib/codex-session-restart'
import {
buildCodexAccountRestartStartup,
buildPaneAccountRestartStartup
} from '@/lib/agent-account-restart-startup'
import { isClaudePaneMidTurn } from '@/lib/claude-session-account-move'
import type { AccountRestartAgent } from '@/store/terminals/terminal-contracts'
import { isForeignMachineCodexPtyId } from '@/lib/codex-pane-selection-lane'
@@ -158,11 +160,15 @@ async function executeDetachedCodexPaneRestart(
reopenCurrentCodexRestartPrompt(located, ptyId)
return
}
const startup = buildCodexAccountRestartStartup({
worktreeId: located.worktreeId,
shellOverride: located.tab.shellOverride
})
const store = useAppStore.getState()
store.suppressPtyExit(ptyId)
store.clearTabPtyId(located.tab.id, ptyId)
store.consumeSuppressedPtyExit(ptyId)
store.queueTabStartupCommand(located.tab.id, { ...CODEX_ACCOUNT_RESTART_STARTUP })
store.queueTabStartupCommand(located.tab.id, startup)
store.clearCodexRestartNotice(ptyId)
killReplacedCodexPanePty(ptyId)
return
@@ -67,6 +67,7 @@ describe('buildPaneAccountRestartStartup for Codex', () => {
beforeEach(() => {
seedAgentStatus(null)
projectRuntimeContext = undefined
useAppStore.setState({ settings: getDefaultSettings('/home/dev') })
})
it('names the session so the relaunch continues the conversation', () => {
@@ -96,11 +97,22 @@ describe('buildPaneAccountRestartStartup for Codex', () => {
expect(startup.startupCommandDelivery).toBe('shell-ready')
})
it('falls back to a bare relaunch when the pane has no Codex session to name', () => {
const startup = build()
it('relaunches the configured command without adding defaults when no session is recorded', () => {
const command = String.raw`& 'C:\Program Files\Codex\codex.exe'`
useAppStore.setState({
settings: {
...getDefaultSettings('/home/dev'),
agentCmdOverrides: { codex: command },
agentDefaultArgs: { codex: '--search' },
agentDefaultEnv: { codex: { CODEX_DEFAULT_ONLY: '1' } }
}
})
expect(startup.command).toBe('codex')
expect(startup.resumeProviderSession).toBeUndefined()
expect(build()).toEqual({
command,
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
})
})
it('uses the persisted record when the live status entry is gone', () => {
@@ -126,7 +138,13 @@ describe('buildPaneAccountRestartStartup for Codex', () => {
expect(startup.resumeProviderSession?.id).toBe(SESSION_ID)
})
it('keeps the bare relaunch for a resolved WSL runtime', () => {
it('keeps the configured fresh relaunch for a resolved WSL runtime', () => {
useAppStore.setState({
settings: {
...getDefaultSettings('/home/dev'),
agentCmdOverrides: { codex: 'codex-wrapper' }
}
})
seedAgentStatus({
agentType: 'codex',
state: 'done',
@@ -146,7 +164,7 @@ describe('buildPaneAccountRestartStartup for Codex', () => {
const startup = build()
expect(startup.command).toBe('codex')
expect(startup.command).toBe('codex-wrapper')
expect(startup.resumeProviderSession).toBeUndefined()
})
@@ -1,8 +1,9 @@
import { useAppStore } from '@/store'
import { useAppStore, type AppState } from '@/store'
import { buildAgentResumeStartupPlan } from '@/lib/tui-agent-startup'
import {
resolveSshHostReportedPlatform,
resolveAgentResumeLaunchTarget
resolveAgentResumeLaunchTarget,
type AgentResumeLaunchTarget
} from '@/lib/agent-resume-launch-target'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
@@ -18,7 +19,9 @@ import type {
SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import type { AccountRestartAgent } from '@/store/terminals/terminal-contracts'
import { CODEX_ACCOUNT_RESTART_STARTUP } from './codex-session-restart'
import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime'
import { resolveAgentLaunchCommand } from '../../../shared/tui-agent-launch-command'
import { resolveStartupShell } from '../../../shared/tui-agent-startup-shell'
export type AgentAccountRestartStartup = {
command: string
@@ -36,6 +39,8 @@ type AccountRestartPane = {
shellOverride?: string
}
type AccountRestartTarget = Pick<AccountRestartPane, 'worktreeId' | 'shellOverride'>
/**
* The resume startup for the pane's own session, or null when it has none to name.
* Capture it before restart clears the old pane's status.
@@ -69,25 +74,10 @@ export function resolveAgentAccountRestartResumeStartup(
) {
return null
}
const worktree = state.getKnownWorktreeById(args.worktreeId)
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
const launchConfig =
(entry ? state.getAgentLaunchConfigForStatusEntry(entry) : undefined) ??
(sleeping?.agent === agent ? sleeping.launchConfig : undefined)
const executionHostId = getExecutionHostIdForWorktree(state, args.worktreeId)
const resumeTarget = resolveAgentResumeLaunchTarget({
projectRuntime,
connectionId: repo?.connectionId,
executionHostId,
worktreePath: worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell,
tabShellOverride: args.shellOverride,
remotePlatform: resolveSshHostReportedPlatform(
state,
executionHostId,
runtimeEnvironmentIdUnlessTwin(state, args.worktreeId)
)
})
const resumeTarget = resolveAccountRestartLaunchTarget(state, args, projectRuntime)
const startupPlan = buildAgentResumeStartupPlan({
agent,
providerSession,
@@ -129,14 +119,59 @@ export function resolveAgentAccountRestartResumeStartup(
}
}
/**
* The startup an account-switch restart relaunches the pane with. Codex falls
* back to a bare relaunch; Claude only ever resumes, so null means skip it.
*/
/** Account-switch startup: Codex can relaunch fresh; Claude requires its recorded conversation. */
export function buildPaneAccountRestartStartup(
agent: AccountRestartAgent,
args: AccountRestartPane
): AgentAccountRestartStartup | null {
const resume = resolveAgentAccountRestartResumeStartup(agent, args)
return resume ?? (agent === 'codex' ? CODEX_ACCOUNT_RESTART_STARTUP : null)
return resume ?? (agent === 'codex' ? buildCodexAccountRestartStartup(args) : null)
}
export function buildCodexAccountRestartStartup(
args: AccountRestartTarget
): AgentAccountRestartStartup {
const state = useAppStore.getState()
const target = resolveAccountRestartLaunchTarget(
state,
args,
getLocalProjectExecutionRuntimeContext(state, args.worktreeId)
)
const command = resolveAgentLaunchCommand({
agent: 'codex',
cmdOverrides: state.settings?.agentCmdOverrides ?? {},
platform: target.platform,
shell: resolveStartupShell(target.platform, target.shell)
})
if (!command.ok) {
throw new Error(command.error)
}
return {
command: command.command,
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
}
}
function resolveAccountRestartLaunchTarget(
state: AppState,
args: AccountRestartTarget,
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): AgentResumeLaunchTarget {
const worktree = state.getKnownWorktreeById(args.worktreeId)
const repo = worktree ? state.repos.find((entry) => entry.id === worktree.repoId) : null
const executionHostId = getExecutionHostIdForWorktree(state, args.worktreeId)
return resolveAgentResumeLaunchTarget({
projectRuntime,
connectionId: repo?.connectionId,
executionHostId,
worktreePath: worktree?.path,
terminalWindowsShell: state.settings?.terminalWindowsShell,
tabShellOverride: args.shellOverride,
remotePlatform: resolveSshHostReportedPlatform(
state,
executionHostId,
runtimeEnvironmentIdUnlessTwin(state, args.worktreeId)
)
})
}
@@ -10,10 +10,8 @@ const markForFocusedHost = (
owner: defaultScopeHost(useAppStore.getState().settings),
...args
})
import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery'
import type { TuiAgent } from '../../../shared/tui-agent'
import {
CODEX_ACCOUNT_RESTART_STARTUP,
markLiveCodexSessionsForRestart,
markRestoredStaleCodexSessionsForRestart
} from './codex-session-restart'
@@ -36,18 +34,6 @@ function setLaunchAgentOnFirstTab(launchAgent: TuiAgent): void {
useAppStore.setState({ tabsByWorktree: { wt1: [{ ...tab, launchAgent }, ...rest] } })
}
describe('CODEX_ACCOUNT_RESTART_STARTUP', () => {
it('waits for shell readiness before relaunching Codex after an account switch', () => {
// launchAgent:codex enables managed-auth readiness/launch prep; omitting it can race handoff and record the wrong account.
expect(CODEX_ACCOUNT_RESTART_STARTUP).toEqual({
command: 'codex',
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
})
expect(shouldUseShellReadyStartupDelivery(CODEX_ACCOUNT_RESTART_STARTUP)).toBe(true)
})
})
describe('markLiveCodexSessionsForRestart', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
const runtimeEnvironmentCall = vi.fn()
@@ -26,13 +26,6 @@ import type { CodexAccountSelectionTarget } from '../../../shared/codex-selectio
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { TuiAgent } from '../../../shared/tui-agent'
// Wait for shell readiness beyond the daemon fast-path timeout; launchAgent:codex also gates managed-auth handoff and correct account recording.
export const CODEX_ACCOUNT_RESTART_STARTUP = {
command: 'codex',
startupCommandDelivery: 'shell-ready',
launchAgent: 'codex'
} as const
export type CodexPaneScanResult = {
ptyId: string
/** The pane may be shown a restart prompt (see isCodexRestartEligiblePane). */
@@ -6,6 +6,7 @@ import { makePaneKey } from '../../src/shared/stable-pane-id'
import { registerPtyHandlers } from '../../src/main/ipc/pty/register-handlers'
import { setLocalPtyProvider } from '../../src/main/ipc/pty/provider/registry'
import { TerminalIntentionalStops } from '../../src/main/runtime/terminal-intentional-stops'
import { getDefaultSettings } from '../../src/shared/constants'
vi.mock('electron', () =>
import('../../src/main/ipc/pty-ipc-mock-registry').then((m) => m.electronModuleMock())
@@ -72,6 +73,7 @@ const cwd = '/tmp/restart'
const tabId = 'tab-1'
const leafId = '11111111-1111-4111-8111-111111111111'
const paneKey = makePaneKey(tabId, leafId)
const configuredCommand = 'codex-wrapper --profile work'
type RestartHarness = ReturnType<typeof installRestartHarness>
@@ -231,7 +233,16 @@ let rendererState: StoreState
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: StoreState) => unknown) => selector(rendererState),
{ getState: () => rendererState, subscribe: () => () => {} }
{
getState: () =>
Object.assign(rendererState, {
getKnownWorktreeById: (id: string) =>
Object.values(rendererState.worktreesByRepo)
.flat()
.find((worktree) => worktree.id === id)
}),
subscribe: () => () => {}
}
)
}))
vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector }))
@@ -264,6 +275,10 @@ describe('account restart through renderer connection and host spawn', () => {
const host = installRestartHarness()
registerWithFakes(mainWindow, host.runtime, host.store)
rendererState = createInitialStoreState(() => rendererState)
rendererState.settings = {
...getDefaultSettings('/home/dev'),
agentCmdOverrides: { codex: configuredCommand }
}
rendererState.tabsByWorktree[worktreeId][0].ptyId = 'pty-old'
rendererState.ptyIdsByTabId = { ...rendererState.ptyIdsByTabId, [tabId]: ['pty-old'] }
rendererState.sleepingAgentSessionsByPaneKey[paneKey] = {
@@ -355,7 +370,7 @@ describe('account restart through renderer connection and host spawn', () => {
expect(window.api.pty.spawn).toHaveBeenCalledWith(
expect.objectContaining({
replacesPtyId: 'pty-old',
command: 'codex',
command: configuredCommand,
launchAgent: 'codex',
startupCommandDelivery: 'shell-ready'
})
@@ -383,6 +398,10 @@ describe('account restart through renderer connection and host spawn', () => {
const host = installRestartHarness()
registerWithFakes(mainWindow, host.runtime, host.store)
rendererState = createInitialStoreState(() => rendererState)
rendererState.settings = {
...getDefaultSettings('/home/dev'),
agentCmdOverrides: { codex: configuredCommand }
}
await installTerminalTestGlobals()
const runtimeCall = vi.fn(
async (args: { method: string; params?: Record<string, unknown> }) => {
@@ -409,7 +428,7 @@ describe('account restart through renderer connection and host spawn', () => {
tabId,
leafId,
worktreeId,
command: 'codex',
command: args.params?.command ?? 'codex',
launchAgent: 'codex',
startupCommandDelivery: 'shell-ready'
})
@@ -441,9 +460,14 @@ describe('account restart through renderer connection and host spawn', () => {
await import('../../src/renderer/src/components/terminal-pane/remote-runtime-pty-transport')
const { releasePaneTransportForRestart } =
await import('../../src/renderer/src/components/terminal-pane/pane-restart-transport-handoff')
const { CODEX_ACCOUNT_RESTART_STARTUP } =
await import('../../src/renderer/src/lib/codex-session-restart')
const options = { worktreeId, tabId, leafId, ...CODEX_ACCOUNT_RESTART_STARTUP }
const { buildCodexAccountRestartStartup } =
await import('../../src/renderer/src/lib/agent-account-restart-startup')
const options = {
worktreeId,
tabId,
leafId,
...buildCodexAccountRestartStartup({ worktreeId })
}
const old = createRemoteRuntimePtyTransport('fake-host', options)
await old.connect({ url: '', callbacks: {} })
expect(old.getPtyId()).toBe('remote:fake-host@@term-old')
@@ -455,9 +479,22 @@ describe('account restart through renderer connection and host spawn', () => {
expect(replacement.getPtyId()).toBe('remote:fake-host@@term-old')
expect(host.shutdown).not.toHaveBeenCalled()
expect(host.providerSpawn.mock.calls.every(([options]) => options.attachOnly)).toBe(true)
expect(
runtimeCall.mock.calls.filter(([args]) => args.method.startsWith('terminal.create'))
).toHaveLength(2)
const creates = runtimeCall.mock.calls.filter(([args]) =>
args.method.startsWith('terminal.create')
)
expect(creates).toHaveLength(2)
if (!hostAuthority) {
for (const [request] of creates) {
expect(request).toMatchObject({
method: 'terminal.create',
params: {
command: configuredCommand,
launchAgent: 'codex',
startupCommandDelivery: 'shell-ready'
}
})
}
}
} finally {
replacement.detach?.()
}