mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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?.()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user