diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts index 1adf05dd802..2189f326520 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.test.ts @@ -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' }) diff --git a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts index 0e93b9b80a0..720aec6f113 100644 --- a/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts +++ b/src/renderer/src/components/terminal-pane/codex-detached-pane-restart.ts @@ -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 diff --git a/src/renderer/src/lib/agent-account-restart-startup.test.ts b/src/renderer/src/lib/agent-account-restart-startup.test.ts index 9bf09d15264..2126d0995cb 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.test.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.test.ts @@ -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() }) diff --git a/src/renderer/src/lib/agent-account-restart-startup.ts b/src/renderer/src/lib/agent-account-restart-startup.ts index 51805e85d63..769b2d50b83 100644 --- a/src/renderer/src/lib/agent-account-restart-startup.ts +++ b/src/renderer/src/lib/agent-account-restart-startup.ts @@ -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 + /** * 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) + ) + }) } diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts index 7fe5e6e60d1..0cc38d04b31 100644 --- a/src/renderer/src/lib/codex-session-restart.test.ts +++ b/src/renderer/src/lib/codex-session-restart.test.ts @@ -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() diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index 7015e3ea3c4..1c180014989 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -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). */ diff --git a/tests/e2e/terminal-account-restart.unit.test.ts b/tests/e2e/terminal-account-restart.unit.test.ts index 9d7ed0b036c..6288a53534a 100644 --- a/tests/e2e/terminal-account-restart.unit.test.ts +++ b/tests/e2e/terminal-account-restart.unit.test.ts @@ -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 @@ -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 }) => { @@ -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?.() }