diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts index 8dc5c902262..61d55ec2106 100644 --- a/src/main/agent-cli-version-probe.ts +++ b/src/main/agent-cli-version-probe.ts @@ -18,6 +18,22 @@ export async function probeAgentCliVersion( input: Pick, supports: (version: string) => boolean ): Promise { + const result = await readAgentCliVersion(input) + if (result.version === null) { + return false + } + if (!supports(result.version)) { + warnRefused(input.program, `${result.version} is not a supported release`) + return false + } + console.info(`[agent-cli-version] ${input.program} --version: ${result.version} is supported`) + return true +} + +export async function readAgentCliVersion( + input: Pick, + parseVersion: typeof parseCliVersion = parseCliVersion +): Promise<{ version: string | null }> { let result try { result = await runProcess({ @@ -29,7 +45,7 @@ export async function probeAgentCliVersion( }) } catch (error) { warnRefused(input.program, `did not start (${String(error)})`) - return false + return { version: null } } const output = `${result.stdout}\n${result.stderr}` if (result.timedOut || result.outputTruncated || result.code !== 0) { @@ -39,19 +55,14 @@ export async function probeAgentCliVersion( ? 'printed too much' : `exited ${result.code ?? result.signal}` warnRefused(input.program, why, output) - return false + return { version: null } } - const version = parseCliVersion(result.stdout) + const version = parseVersion(parseVersion === parseCliVersion ? result.stdout : output) if (version === null) { warnRefused(input.program, 'printed no version', output) - return false + return { version: null } } - if (!supports(version)) { - warnRefused(input.program, `${version} is not a supported release`) - return false - } - console.info(`[agent-cli-version] ${input.program} --version: ${version} is supported`) - return true + return { version } } /** A stable release at or after `floor`, on any later major line too. */ diff --git a/src/main/codex/codex-cli-installation-error.ts b/src/main/codex/codex-cli-installation-error.ts new file mode 100644 index 00000000000..f4344046c45 --- /dev/null +++ b/src/main/codex/codex-cli-installation-error.ts @@ -0,0 +1,75 @@ +import type { CodexInstallationProblem } from '../../shared/codex-cli-installation' +import { sayAgentSessionFailureEnglish } from '../../shared/agent-session-failure-copy' +import { readCodexCliInstallation } from '../preflight/codex-cli-installation' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentLaunchResolver } from '../runtime/structured-agent-launch-composition' +import { + resolveProviderChildEnv, + type ProviderProcessLaunch +} from '../provider-process/provider-process-launch' + +export class CodexCliInstallationError extends AgentSessionPreSpawnError { + readonly codexInstallation: CodexInstallationProblem + + constructor(problem: CodexInstallationProblem) { + super( + sayAgentSessionFailureEnglish( + problem.installedVersion === null ? 'cliMissing' : 'codexCliTooOld', + { + agent: 'Codex', + installedVersion: problem.installedVersion ?? '', + minimumVersion: problem.minimumVersion + } + ) + ) + this.codexInstallation = problem + } +} + +export function codexInstallationProblemOf( + error: unknown, + depth = 0 +): CodexInstallationProblem | undefined { + if (depth >= 6 || !(error instanceof Error)) { + return undefined + } + if (error instanceof CodexCliInstallationError) { + return error.codexInstallation + } + return codexInstallationProblemOf(error.cause, depth + 1) +} + +export async function requireSupportedCodexCli( + input: Parameters[0], + readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation +): Promise { + const result = await readInstallation(input) + if (result.status === 'unsupported') { + throw new CodexCliInstallationError({ + installedVersion: result.version, + minimumVersion: result.minimumVersion + }) + } + if (result.status === 'unknown') { + console.warn(`[codex-cli-version] Could not verify ${input.program}; allowing structured chat`) + } +} + +/** Check every acquired child against the same sealed launch the app-server will use. */ +export function withSupportedCodexCli( + resolveLaunch: StructuredAgentLaunchResolver, + readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation +): StructuredAgentLaunchResolver { + return async (request) => { + const launch = await resolveLaunch(request) + await requireSupportedCodexCli( + { + program: launch.command, + cwd: launch.cwd, + env: resolveProviderChildEnv(launch, process.env) + }, + readInstallation + ) + return launch + } +} diff --git a/src/main/codex/codex-minimum-version.test.ts b/src/main/codex/codex-minimum-version.test.ts new file mode 100644 index 00000000000..1f94f6dca47 --- /dev/null +++ b/src/main/codex/codex-minimum-version.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child' +import { codexCliInstallation } from '../../shared/codex-cli-installation' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure' +import { resolveAgentLaunchCommand } from '../../shared/tui-agent-launch-command' +import { readCodexCliInstallation } from '../preflight/codex-cli-installation' +import { + failedAcquisitionRefusal, + failedAcquisitionSettlement +} from '../native-chat/agent-session-wire/structured-agent-session-failed-create-refusal' +import { structuredAgentSessionStartFailure } from '../native-chat/agent-session-wire/structured-agent-session-failure-text' +import { + CodexCliInstallationError, + requireSupportedCodexCli, + withSupportedCodexCli +} from './codex-cli-installation-error' +import { + createCodexStructuredLaunchResolver, + TEST_SPAWN_TOKEN +} from '../runtime/structured-agent-launch-composition.test-support' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' +import { identityFor } from './codex-structured-session-adapter-fixture' +import { openCodexAppServerConnection } from './codex-app-server-connection' + +vi.mock('../preflight/codex-cli-installation', () => ({ readCodexCliInstallation: vi.fn() })) +beforeEach(() => { + vi.mocked(readCodexCliInstallation).mockReset() + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0')) +}) +afterEach(() => vi.restoreAllMocks()) + +function record(): AgentSessionRecord { + return { + ...agentSessionRecordFixture(), + provider: 'codex' as const, + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME' as const, path: '/account' } + } +} + +describe('Codex structured launch version admission', () => { + it.each(['0.100.0', '0.135.0'])( + 'refuses %s with concise repair copy and both version facts', + async (version) => { + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, version)) + const caught = await requireSupportedCodexCli({ program: '/execution-host/codex' }).catch( + (error: unknown) => error + ) + expect(caught).toBeInstanceOf(CodexCliInstallationError) + const wording = { record: record(), newSession: true } + const result = failedAcquisitionRefusal(caught, wording) + expect(result?.refusal.message).toBe( + `Codex ${version} is too old for chats. Update to 0.136.0 or newer.` + ) + expect(result?.refusal.message).not.toContain('Settings → Agents') + expect(result?.refusal.message).not.toContain('codex update') + expect(failedAcquisitionSettlement(caught, wording).exitProof).toBe('processless') + const failure = structuredAgentSessionStartFailure({ error: caught }) + expect(failure.reason).toBe(result?.refusal.message) + expect(readWholeAgentSessionFailureFact(failure.rejection)).toEqual(failure.rejection) + if (!result) { + throw new Error('Expected refusal') + } + expect( + structuredAgentSessionStartFailure({ refusal: result.refusal, newSession: true }).reason + ).toBe(failure.reason) + } + ) + + it('leaves a missing Codex to the existing spawn refusal', async () => { + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(false, null)) + await expect(requireSupportedCodexCli({ program: 'codex' })).resolves.toBeUndefined() + }) + + it('reports Windows ENOENT through the existing not-found failure with one explanation', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(false, null)) + await requireSupportedCodexCli({ program: 'codex.exe' }) + const child = createFakePipedChild() + Object.assign(child, { pid: undefined }) + const opening = openCodexAppServerConnection( + { command: 'codex.exe', args: ['app-server'] }, + {}, + () => child + ).catch((error: unknown) => error) + child.emit( + 'error', + Object.assign(new Error('spawn codex.exe ENOENT'), { code: 'ENOENT', path: 'codex.exe' }) + ) + child.emit('close', -2, null) + const failure = structuredAgentSessionStartFailure( + { error: await opening }, + { agentName: 'Codex' } + ) + expect(failure.rejection.kind).toBe('cliMissing') + expect(failure.reason).toBe( + "Codex wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents." + ) + }) + + it('allows an unknown version with a log line', async () => { + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, null)) + const log = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect(requireSupportedCodexCli({ program: '/host/codex' })).resolves.toBeUndefined() + expect(log).toHaveBeenCalledWith(expect.stringContaining('allowing structured chat')) + log.mockRestore() + }) + + it.each(['start', 'resume'] as const)( + 'checks the exact host binary and launch environment before %s', + async (mode) => { + const value = record() + if (mode === 'resume') { + value.providerHandleChain = [ + { + linkId: 'link', + handle: codexProviderHandle('thread'), + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ] + } + const resolve = withSupportedCodexCli( + createCodexStructuredLaunchResolver({ + store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, + resolveCommand: () => '/host/bin/codex', + resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }), + resolveLaunchArgs: () => [], + resolveWorkspacePath: async () => '/folder', + resolveRollout: async () => null + }) + ) + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.135.0')) + await expect( + resolve({ identity: identityFor(value.sessionId), spawnToken: TEST_SPAWN_TOKEN }) + ).rejects.toThrow('0.135.0') + const probe = vi.mocked(readCodexCliInstallation).mock.calls.at(-1)?.[0] + expect(probe?.program).toBe('/host/bin/codex') + expect(probe?.cwd).toBe('/folder') + expect(probe?.env?.PATH).toContain('/host/bin') + expect(probe?.env?.HOME).toBe('/host/home') + expect(probe?.env?.CODEX_HOME).toBe('/account') + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0')) + const launch = await resolve({ + identity: identityFor(value.sessionId), + spawnToken: TEST_SPAWN_TOKEN + }) + expect(launch).toMatchObject({ command: '/host/bin/codex', args: ['app-server'] }) + expect(vi.mocked(readCodexCliInstallation).mock.calls.at(-1)?.[0]).toEqual({ + program: launch.command, + cwd: launch.cwd, + env: resolveProviderChildEnv(launch, process.env) + }) + } + ) + + it.each(['start', 'resume'] as const)( + 'admits the workspace-selected version on %s', + async (mode) => { + const value = record() + if (mode === 'resume') { + value.providerHandleChain = [ + { + linkId: 'link', + handle: codexProviderHandle('thread'), + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ] + } + vi.mocked(readCodexCliInstallation).mockImplementation(async (input) => + codexCliInstallation(true, input.cwd === '/workspace' ? '0.136.0' : '0.135.0') + ) + const resolve = withSupportedCodexCli( + createCodexStructuredLaunchResolver({ + store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, + resolveCommand: () => '/host/project-aware-codex', + resolveEnvironment: async () => ({ PATH: '/host/bin' }), + resolveLaunchArgs: () => [], + resolveWorkspacePath: async () => '/workspace', + resolveRollout: async () => null + }) + ) + expect( + (await resolve({ identity: identityFor(value.sessionId), spawnToken: TEST_SPAWN_TOKEN })) + .cwd + ).toBe('/workspace') + } + ) + + it.each(['posix', 'powershell', 'cmd'] as const)( + 'leaves terminal Codex launch unchanged in %s', + (shell) => { + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.135.0')) + expect( + resolveAgentLaunchCommand({ + agent: 'codex', + cmdOverrides: {}, + platform: shell === 'posix' ? 'linux' : 'win32', + shell, + agentArgs: '--model chosen' + }) + ).toMatchObject({ + ok: true, + command: shell === 'cmd' ? 'codex "--model" "chosen"' : "codex '--model' 'chosen'" + }) + expect(readCodexCliInstallation).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index efd32b666cd..21f71cdaa11 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -125,37 +125,23 @@ describe('openCodexThread', () => { ) }) - it('caches a narrowly proven excludeTurns refusal and uses one bounded fallback', async () => { - const request = vi.fn(async (_method: string, params?: Record) => { - if (params?.excludeTurns) { - throw new CodexAppServerRequestError( - 'thread/resume', - -32602, - 'codex app-server thread/resume failed: unknown field `excludeTurns`' - ) - } - return { thread: { id: 'thread-1', turns: [{ id: 'turn-1', items: [] }] } } + it('does not retry without excludeTurns after an unsupported-field refusal', async () => { + const refusal = new CodexAppServerRequestError( + 'thread/resume', + -32602, + 'unknown field excludeTurns' + ) + const request = vi.fn(async () => { + throw refusal }) - const connection = connectionFor(request) - - const first = await openCodexThread( - connection, - { cwd: '/workspace', resumeThreadId: 'thread-1' }, - 2_000 - ) - const second = await openCodexThread( - connection, - { cwd: '/workspace', resumeThreadId: 'thread-1' }, - 2_000 - ) - - expect(first.thread?.turns).toHaveLength(1) - expect(second.thread?.turns).toHaveLength(1) - expect(request.mock.calls.map(([, params]) => params)).toEqual([ - expect.objectContaining({ excludeTurns: true }), - { threadId: 'thread-1', cwd: '/workspace' }, - { threadId: 'thread-1', cwd: '/workspace' } - ]) + await expect( + openCodexThread( + connectionFor(request), + { cwd: '/workspace', resumeThreadId: 'thread-1' }, + 2_000 + ) + ).rejects.toBe(refusal) + expect(request).toHaveBeenCalledOnce() }) it('does not retry ambiguous invalid params or oversized history responses', async () => { @@ -190,33 +176,6 @@ describe('openCodexThread', () => { expect(oversizedRequest).toHaveBeenCalledOnce() }) - it('accepts a fallback result beyond the daemon wire limit', async () => { - const request = vi.fn(async (_method: string, params?: Record) => { - if (params?.excludeTurns) { - throw new CodexAppServerRequestError( - 'thread/resume', - -32602, - 'codex app-server thread/resume failed: unsupported excludeTurns parameter' - ) - } - return { - thread: { - id: 'thread-1', - turns: [{ id: 'turn-1', items: [{ output: 'x'.repeat(16 * 1024 * 1024 + 1) }] }] - } - } - }) - - await expect( - openCodexThread( - connectionFor(request), - { cwd: '/workspace', resumeThreadId: 'thread-1' }, - 2_000 - ) - ).resolves.toMatchObject({ threadId: 'thread-1' }) - expect(request).toHaveBeenCalledTimes(2) - }) - describe('a thread Codex never saved', () => { const noRollout = (threadId: string, code = -32600) => new CodexAppServerRequestError( diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 6fd58ba17ec..c6e686869b7 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -28,41 +28,6 @@ function nonEmptyString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value : null } -const resumeMetadataUnsupported = new WeakSet() - -function isExcludeTurnsUnsupported(error: unknown): boolean { - return ( - isCodexAppServerRequestError(error) && - error.code === -32602 && - /(?:unknown|unexpected|unsupported|unrecognized).{0,80}excludeTurns|excludeTurns.{0,80}(?:unknown|unexpected|unsupported|unrecognized)/i.test( - error.message - ) - ) -} - -async function resumeCodexThread( - connection: Pick, - params: Record, - timeoutMs: number | undefined -): Promise { - if (resumeMetadataUnsupported.has(connection)) { - return connection.request('thread/resume', params, { timeoutMs }) - } - try { - return await connection.request( - 'thread/resume', - { ...params, excludeTurns: true }, - { timeoutMs } - ) - } catch (error) { - if (!isExcludeTurnsUnsupported(error)) { - throw error - } - resumeMetadataUnsupported.add(connection) - return connection.request('thread/resume', params, { timeoutMs }) - } -} - /** * Codex's own answer that it holds no rollout for this exact thread: the thread was started but * never given input, so there is no conversation to lose. Codex matches the same exact text @@ -120,7 +85,11 @@ export async function openCodexThread( ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { - opened = await resumeCodexThread(connection, resumeParams, timeoutMs) + opened = await connection.request( + 'thread/resume', + { ...resumeParams, excludeTurns: true }, + { timeoutMs } + ) } catch (error) { if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts new file mode 100644 index 00000000000..b01020f7203 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { CodexCliInstallationError } from '../../codex/codex-cli-installation-error' +import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' +import { projectQueuedMessageCards } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' +import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data' +import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ + restartable: true, + idleSweep: { idleMs: 0, intervalMs: 60 * 60 * 1000 } + }) +}) +afterEach(() => rig.dispose()) + +async function retainedCards() { + const result = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + if (!result.ok) { + throw new Error('History refused') + } + const { page } = result + const cards = projectQueuedMessageCards(page.queuedMessages, page.submissions, { + queuePaused: Boolean(page.queuePause) + }) + return { page, cards } +} + +async function retainedDraft() { + const working = await rig.workingSend() + const queued = await rig.send('retained instruction', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('Expected a queued draft') + } + await rig.stop() + await rig.settleAccepted(working, 'earlier-turn') + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + rig.starts.mockImplementationOnce(() => { + throw new CodexCliInstallationError({ + installedVersion: '0.135.0', + minimumVersion: '0.136.0' + }) + }) + return queued.value.queued.messageId +} + +it.each(['returned', 'held'] as const)( + 'uses per-message Send after an existing conversation restart is version-refused with a %s draft and no pause', + async (kind) => { + let id = await retainedDraft() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect((await rig.handoff(id))?.dispatchState).toBe('rejected')) + const rejected = await rig.handoffId(id) + if (kind === 'held') { + await rig.deleteQueued(id) + id = 'held-draft' + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!journal) { + throw new Error('Missing journal') + } + await journal.queuedMessages.insert({ + messageId: id, + body: hostTestMessage('retained instruction'), + fingerprint: 'retained-instruction', + hostInstance: structuredAgentSessionHostInstance(), + holdReason: 'send_failed' + }) + } + const { page, cards } = await retainedCards() + expect(page.queuePause ?? null).toBeNull() + expect(cards).toMatchObject([{ messageId: id, hold: kind === 'held' ? 'paused' : 'returned' }]) + expect( + page.items.filter((item) => isStructuredAgentSessionStartFailureRow(item.itemId)) + ).toMatchObject([ + { + body: { + text: 'Codex 0.135.0 is too old for chats. Update to 0.136.0 or newer.', + tone: 'error', + failure: { refusal: { details: { codexInstallation: { installedVersion: '0.135.0' } } } } + } + } + ]) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } }) + const attempts = rig.starts.mock.calls.length + expect((await rig.submission(rejected))?.handedOverAt).toBeUndefined() + expect(rig.starts).toHaveBeenCalledTimes(attempts) + expect(await rig.sendNow(id)).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.handoff(id))?.handedOverAt).toBeDefined()) + expect(await rig.handoffId(id)).not.toBe(rejected) + expect(rig.starts).toHaveBeenCalledTimes(attempts + 1) + expect((await rig.submission(rejected))?.dispatchState).toBe('rejected') + } +) + +it('keeps an actual pause after a version refusal and releases it through the host', async () => { + const id = await retainedDraft() + rig.starts.mockReset() + expect(await rig.queuePause()).not.toBeNull() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect((await rig.handoff(id))?.handedOverAt).toBeDefined()) + expect(await rig.queuePause()).toBeNull() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index 82188da9286..d88997c46c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -30,6 +30,7 @@ import { import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { providerExecutableMissing } from '../../provider-process/provider-executable-missing' import { argumentProblemOf } from '../structured-agent-arguments-error' +import { codexInstallationProblemOf } from '../../codex/codex-cli-installation-error' import { providerDiagnosticOf } from '../../../shared/agent-session-failure' /** Who a failed acquisition's sentence names, and whether it was the session's first start. */ @@ -113,6 +114,10 @@ function isExitProvenAcquisitionFailure(error: unknown): error is Error { function failedAcquisitionDetails( error: unknown ): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { + const codexInstallation = codexInstallationProblemOf(error) + if (codexInstallation) { + return { reason: 'attachFailed', codexInstallation } + } const argumentProblem = argumentProblemOf(error) if (argumentProblem) { return { reason: 'attachFailed', argumentProblem } @@ -142,7 +147,8 @@ export function failedAcquisitionRefusal( if ( error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error) || - (isAgentSessionPreSpawnError(error) && argumentProblemOf(error)) + (isAgentSessionPreSpawnError(error) && + (argumentProblemOf(error) || codexInstallationProblemOf(error))) ) { return { ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 37f728808b2..02d248288e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -22,6 +22,7 @@ import { import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { providerExecutableMissing } from '../../provider-process/provider-executable-missing' import { argumentProblemOf } from '../structured-agent-arguments-error' +import { codexInstallationProblemOf } from '../../codex/codex-cli-installation-error' /** Start refusals whose situation is itself what the person reads, with its own next step. */ const TYPED_START_REFUSALS = [ @@ -72,6 +73,15 @@ export function providerExitObserved(error: unknown, depth = 0): boolean { * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + const codexInstallation = codexInstallationProblemOf(cause) + if (codexInstallation) { + return agentSessionFailureFact('startFailed', { + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed', codexInstallation } + } + }) + } const typed = typedStartRefusal( cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined ) diff --git a/src/main/preflight/codex-cli-installation.test.ts b/src/main/preflight/codex-cli-installation.test.ts new file mode 100644 index 00000000000..6ffe6aadbad --- /dev/null +++ b/src/main/preflight/codex-cli-installation.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readCodexCliInstallation } from './codex-cli-installation' + +const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('@orca/process-host', async (original) => ({ + ...(await original()), + runProcess +})) + +function prints(stdout: string, timedOut = false): void { + runProcess.mockResolvedValue({ code: 0, stdout, stderr: '', timedOut }) +} +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + runProcess.mockReset() +}) + +describe('Codex installation version probes', () => { + it('probes every read and sees changes behind an unchanged launcher', async () => { + const program = '/fake/codex' + const input = { program, env: { PATH: '/launch/path' } } + for (const version of ['0.135.0', '0.136.0', '0.135.0']) { + prints(`company-wrapper 0.1.0\ncodex-cli ${version}`) + expect((await readCodexCliInstallation(input)).version).toBe(version) + } + expect(runProcess).toHaveBeenCalledTimes(3) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program, + env: input.env, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + ) + }) + + it('allows a timeout and reads the next version without waiting', async () => { + const program = '/fake/codex' + prints('codex-cli 0.135.0', true) + expect((await readCodexCliInstallation({ program })).status).toBe('unknown') + prints('codex-cli 0.136.0') + expect((await readCodexCliInstallation({ program })).status).toBe('ready') + expect(runProcess).toHaveBeenCalledTimes(2) + }) + + it.each(['ENOENT', 'EACCES'])( + 'leaves a failed version spawn (%s) to the existing chat failure', + async (code) => { + const program = '/fake/codex' + runProcess.mockRejectedValue(Object.assign(new Error('unavailable'), { code })) + expect((await readCodexCliInstallation({ program })).status).toBe('unknown') + } + ) +}) diff --git a/src/main/preflight/codex-cli-installation.ts b/src/main/preflight/codex-cli-installation.ts new file mode 100644 index 00000000000..d33d192b1f1 --- /dev/null +++ b/src/main/preflight/codex-cli-installation.ts @@ -0,0 +1,14 @@ +import { + codexCliInstallation, + parseCodexCliVersion, + type CodexCliInstallation +} from '../../shared/codex-cli-installation' +import type { ProcessSpec } from '@orca/process-host/process-spec' +import { readAgentCliVersion } from '../agent-cli-version-probe' + +export async function readCodexCliInstallation( + input: Pick +): Promise { + const result = await readAgentCliVersion(input, parseCodexCliVersion) + return codexCliInstallation(true, result.version) +} diff --git a/src/main/preflight/codex-workspace-relative-path.test.ts b/src/main/preflight/codex-workspace-relative-path.test.ts new file mode 100644 index 00000000000..d162c4c3b29 --- /dev/null +++ b/src/main/preflight/codex-workspace-relative-path.test.ts @@ -0,0 +1,128 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { runProcess } from '@orca/process-host' +import { + createCodexStructuredLaunchResolver, + TEST_SPAWN_TOKEN +} from '../runtime/structured-agent-launch-composition.test-support' +import { withSupportedCodexCli } from '../codex/codex-cli-installation-error' +import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution' +import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { createStructuredAgentEnvironmentResolvers } from '../runtime/structured-agent-shell-environment' +import { identityFor } from '../codex/codex-structured-session-adapter-fixture' +import { readCodexCliInstallation } from './codex-cli-installation' +import type * as CommandResolution from '../../shared/node-cli-command-resolution' + +vi.mock('../startup/login-shell-environment', () => ({ + resolveLoginShellEnvironment: async () => ({}) +})) +vi.mock('../../shared/node-cli-command-resolution', async (original) => { + const actual = await original() + return { + ...actual, + // Keep the stock resolver from substituting any real host installation. + resolveCliCommand: (name: string) => name + } +}) + +let root: string | undefined +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } +}) + +describe.skipIf(process.platform === 'win32')('workspace-relative Codex selection', () => { + it.each(['bin', 'node_modules/.bin', '.'])( + 'admits the child selected through PATH=%s and recovers once it is updated', + async (entry) => { + root = await mkdtemp(join(tmpdir(), 'codex-relative-path-')) + const cwd = root + const directory = join(cwd, entry) + await mkdir(directory, { recursive: true }) + const program = join(directory, 'codex') + const versionFile = `${program}.version` + await writeFile(versionFile, '0.136.0') + await writeFile( + program, + `#!${process.execPath}\nconst fs = require('node:fs')\n` + + `const file = __filename + '.version'\n` + + `console.log('codex-cli ' + fs.readFileSync(file, 'utf8'))\n`, + { mode: 0o755 } + ) + const settings = { agentDefaultEnv: { codex: { PATH: entry } } } + const sources = { + resolveCommand: (options?: { pathEnv?: string | null; homePath?: string }) => + resolveStructuredAgentCommand('codex', settings, options), + resolveAgentLaunchEnv: () => resolveTuiAgentLaunchEnv('codex', settings.agentDefaultEnv) + } + const environment = createStructuredAgentEnvironmentResolvers(sources) + const record: AgentSessionRecord = { + ...agentSessionRecordFixture(), + provider: 'codex' as const, + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME' as const, path: join(cwd, 'account') }, + location: { ...agentSessionRecordFixture().location, workspaceKind: 'folder' as const } + } + const resolve = withSupportedCodexCli( + createCodexStructuredLaunchResolver({ + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, + resolveCommand: sources.resolveCommand, + resolveEnvironment: () => environment.resolveAgentEnvironment('codex'), + resolveLaunchArgs: () => [], + resolveWorkspacePath: async () => cwd, + resolveRollout: async () => null + }) + ) + for (const resume of [false, true]) { + const value = agentSessionRecordFixture() + record.providerHandleChain = resume + ? [{ ...value.providerHandleChain[0], handle: codexProviderHandle('thread') }] + : [] + const launch = await resolve({ + identity: identityFor(record.sessionId), + spawnToken: TEST_SPAWN_TOKEN + }) + const actual = await runProcess({ + program: launch.command, + cwd: launch.cwd, + env: launch.env, + args: ['--version'] + }) + expect(actual.code).toBe(0) + expect(actual.stdout.trim()).toBe('codex-cli 0.136.0') + await writeFile(versionFile, '0.135.0') + await expect( + resolve({ identity: identityFor(record.sessionId), spawnToken: TEST_SPAWN_TOKEN }) + ).rejects.toThrow('0.135.0') + // An external update is checked again on the next chat start. + await writeFile(versionFile, '0.136.0') + await expect( + resolve({ identity: identityFor(record.sessionId), spawnToken: TEST_SPAWN_TOKEN }) + ).resolves.toMatchObject({ + cwd + }) + } + } + ) + + it('resolves a relative command path before probing it', async () => { + root = await mkdtemp(join(tmpdir(), 'codex-relative-command-')) + await mkdir(join(root, 'bin')) + const file = join(root, 'bin', 'codex') + await writeFile(file, `#!${process.execPath}\nconsole.log('codex-cli 0.136.0')\n`, { + mode: 0o755 + }) + const input = { program: join('bin', 'codex'), cwd: root, env: { PATH: '' } } + expect((await readCodexCliInstallation(input)).status).toBe('ready') + await writeFile(file, `#!${process.execPath}\nconsole.log('codex-cli 0.135.0')\n`, { + mode: 0o755 + }) + expect((await readCodexCliInstallation(input)).status).toBe('unsupported') + }) +}) diff --git a/src/main/runtime/scripted-codex-transport.test-fixture.ts b/src/main/runtime/scripted-codex-transport.test-fixture.ts new file mode 100644 index 00000000000..851e0d36f32 --- /dev/null +++ b/src/main/runtime/scripted-codex-transport.test-fixture.ts @@ -0,0 +1,20 @@ +import { + CODEX_MINIMUM_SUPPORTED_VERSION, + codexCliInstallation +} from '../../shared/codex-cli-installation' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +/** Runtime deps that put a scripted Codex child where the real app-server would spawn. It speaks + * the supported protocol, so it reports that version; its fake pid has a fixed start time. */ +export function scriptedCodexTransport( + openConnection: NonNullable +): Pick< + StructuredAgentSessionRuntimeDeps, + 'openCodexConnection' | 'readCodexInstallation' | 'readProcessStartTime' +> { + return { + openCodexConnection: openConnection, + readCodexInstallation: async () => codexCliInstallation(true, CODEX_MINIMUM_SUPPORTED_VERSION), + readProcessStartTime: async () => 1_700_000_000_000 + } +} diff --git a/src/main/runtime/structured-agent-launch-parity.test.ts b/src/main/runtime/structured-agent-launch-parity.test.ts index 40b498046b4..ba7e25bd891 100644 --- a/src/main/runtime/structured-agent-launch-parity.test.ts +++ b/src/main/runtime/structured-agent-launch-parity.test.ts @@ -156,7 +156,7 @@ type Agent = (typeof AGENTS)[number] /** What each fake binary prints for `--version`: a release every launch gate accepts. */ const VERSIONS: Record = { claude: '2.1.120 (Claude Code)', - codex: 'codex-cli 0.130.0', + codex: 'codex-cli 0.136.0', grok: '1.0.50', opencode: '2.0.20', omp: '17.1.0', diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index 59afa69a92c..170a48a18f9 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -1,5 +1,6 @@ // Each runtime registration owns its adapter, location support and account resolution at start. +import { withSupportedCodexCli } from '../codex/codex-cli-installation-error' import { codexStructuredLaunchPart } from '../codex/codex-structured-launch-resolution' import { claudeStructuredLaunchPart } from '../claude/claude-structured-launch-resolution' import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support' @@ -131,16 +132,19 @@ function createCodexAdapter(context: StructuredAgentAdapterContext): StructuredA const { deps, followUps, host } = context return new CodexStructuredSessionAdapter({ resolveAccountKind: deps.resolveCodexAccountKind, - resolveLaunch: composeStructuredLaunch( - CODEX_STRUCTURED_AGENT, - structuredAgentLaunchSources(context), - codexStructuredLaunchPart({ - resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), - ...(deps.resolveCodexPermissionPolicy - ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } - : {}), - ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) - }) + resolveLaunch: withSupportedCodexCli( + composeStructuredLaunch( + CODEX_STRUCTURED_AGENT, + structuredAgentLaunchSources(context), + codexStructuredLaunchPart({ + resolveLaunchArgs: () => deps.resolveLaunchArgs('codex'), + ...(deps.resolveCodexPermissionPolicy + ? { resolvePermissionPolicy: deps.resolveCodexPermissionPolicy } + : {}), + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }) + ), + deps.readCodexInstallation ), ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts index a1095f28ccc..617ff88b0db 100644 --- a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -36,6 +36,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' const CALLER = { callerKey: 'codex-interrupt-order-test' } const MODEL = { @@ -220,8 +221,7 @@ beforeEach(async () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() diff --git a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts index 6e3e94a3fa1..a49d89ddd5f 100644 --- a/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts +++ b/src/main/runtime/structured-agent-session-codex-opening-send-hold.test.ts @@ -39,6 +39,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { @@ -237,8 +238,7 @@ beforeEach(async () => { resolveCodexCommand: () => 'codex', resolveLaunchArgs: () => [], resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() diff --git a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts index 7c07255edb3..f4fc4d54968 100644 --- a/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-settlement-order.test.ts @@ -39,6 +39,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' const CALLER = { callerKey: 'codex-settlement-order-test' } const MODEL = { @@ -164,8 +165,7 @@ beforeEach(async () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 02c7ae282f2..a9633ae80dc 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -40,6 +40,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' const CALLER = { callerKey: 'codex-stopped-send-resume-test' } const MODEL = { @@ -177,8 +178,7 @@ async function startHost(): Promise { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) } diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index b57b6a3e1b5..f41061392fd 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -41,6 +41,7 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { @@ -280,8 +281,7 @@ beforeEach(async () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 7232960bfa9..deaee6763c3 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -46,6 +46,7 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' const journals = createTrackedJournalOpener() @@ -364,8 +365,7 @@ beforeEach(async () => { codexOverrideReads += 1 return { CODEX_PROFILE: configuredCodexProfile } }, - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(codex.openConnection) }).then(() => undefined), registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => ({ releaseIfCurrent: dispose diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index f5ed248e736..36e1e514d75 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -19,6 +19,7 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' describe('structured session runtime provider-exit wiring', () => { let root: string | null = null @@ -91,8 +92,7 @@ describe('structured session runtime provider-exit wiring', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() @@ -197,8 +197,7 @@ describe('structured session runtime provider-exit wiring', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() @@ -229,8 +228,7 @@ describe('structured session runtime provider-exit wiring', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) await restarted.restoreReadableSessions() const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) @@ -304,8 +302,7 @@ describe('structured session runtime provider-exit wiring', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() @@ -412,8 +409,7 @@ describe('structured session runtime provider-exit wiring', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(openConnection) }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 64e44ab74c5..637cc61aaf2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -26,6 +26,7 @@ import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' import type { CodexStructuredPermissionPolicy } from '../codex/codex-structured-permission-policy' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' +import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import { StructuredAgentSessionHost, @@ -113,6 +114,8 @@ export type StructuredAgentSessionRuntimeDeps = { } /** Provider transports are overridden only to drive the runtime against scripted children. */ openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + /** Scripted transports must also replace the real host's version probe. */ + readCodexInstallation?: typeof readCodexCliInstallation openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection'] /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 8155b889b37..bd74bdd1b75 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -35,6 +35,7 @@ import { type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' export const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' export const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' @@ -257,8 +258,7 @@ beforeEach(async () => { resolveLaunchArgs: () => [], resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveEnvironment: async () => ({ PATH: '/usr/bin' }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, + ...scriptedCodexTransport(codex.openConnection), // The same calls the runtime's own host install makes. onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary), onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId) diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index d826b2f1b2f..949afdb89dc 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -34,6 +34,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) @@ -128,8 +129,7 @@ describe('the chat strip and the session list read the same host child records', resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000, + ...scriptedCodexTransport(openConnection), statusSink }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index 3fc2599c3eb..bd29562320e 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -20,6 +20,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' import { AgentHookServer } from '../agent-hooks/server' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule' @@ -88,8 +89,7 @@ describe('structured Codex child work through the production runtime', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: openConnection, - readProcessStartTime: async () => 1_700_000_000_000, + ...scriptedCodexTransport(openConnection), statusSink: { publish: () => {}, forget: () => {}, diff --git a/src/main/runtime/structured-codex-session-rpc-test-harness.ts b/src/main/runtime/structured-codex-session-rpc-test-harness.ts index ebb54a15061..67ff1574b75 100644 --- a/src/main/runtime/structured-codex-session-rpc-test-harness.ts +++ b/src/main/runtime/structured-codex-session-rpc-test-harness.ts @@ -29,6 +29,7 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' export const SESSION = 'session-integration-1' export const THREAD = 'thread-integration' @@ -229,8 +230,7 @@ export async function openStructuredCodexRpcHarness( }), resolveAgentLaunchEnv: (agent): Record => agent === 'codex' ? { CODEX_PROFILE: 'configured' } : {}, - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000 + ...scriptedCodexTransport(codex.openConnection) }) const runtime = { getRuntimeId: () => 'runtime-1', diff --git a/src/main/runtime/structured-foreground-command-strip.test.ts b/src/main/runtime/structured-foreground-command-strip.test.ts index 3d9bd0060c0..2e819f4daf4 100644 --- a/src/main/runtime/structured-foreground-command-strip.test.ts +++ b/src/main/runtime/structured-foreground-command-strip.test.ts @@ -15,6 +15,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { scriptedCodexTransport } from './scripted-codex-transport.test-fixture' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) @@ -61,8 +62,7 @@ describe('foreground commands in the background-task channel', () => { resolveClaudeAuthPolicy: () => ({ account: 'managed' }), resolveCodexCommand: () => 'codex', resolveEnvironment: async () => ({ PATH: process.env.PATH }), - openCodexConnection: codex.openConnection, - readProcessStartTime: async () => 1_700_000_000_000, + ...scriptedCodexTransport(codex.openConnection), statusSink: { publish: (summary, subject) => server.ingestStructuredStatus(summary, subject), forget: (subject) => server.dropStructuredStatus(subject), diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 777936a1c93..5843947753b 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -3,6 +3,7 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import type { AgentSessionFailureFact } from '../../../../shared/agent-session-failure' import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' import { TooltipProvider } from '@/components/ui/tooltip' import { MessageRow } from './NativeChatMessageRow' @@ -419,3 +420,41 @@ describe('notice rows', () => { expect(screen.queryByText('Words an older host wrote')).toBeNull() }) }) + +describe('Codex installation in notice rows', () => { + it.each([null, '0.135.0'])( + 'shows the plain host failure through the ordinary notice row: %s', + (installedVersion) => { + const failure: AgentSessionFailureFact = { + kind: installedVersion === null ? 'cliMissing' : 'startFailed', + ...(installedVersion === null + ? {} + : { + refusal: { + code: 'agent_session_operation_invalid', + details: { + reason: 'attachFailed', + codexInstallation: { installedVersion, minimumVersion: '0.136.0' } + } + } + }) + } + const text = installedVersion + ? 'Codex 0.135.0 is too old for chats. Update to 0.136.0 or newer.' + : "Codex wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents." + renderStatus( + { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(failure, { agentName: 'Codex', surface: 'row' }) + }, + null, + false, + false, + 'Codex' + ) + expect(screen.getByText(text)).toBeInTheDocument() + expect(screen.queryByRole('button')).toBeNull() + } + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx index 3dc83d883e8..757f5576c4b 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.approval-and-start-failure.test.tsx @@ -197,7 +197,10 @@ afterEach(() => { } // The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. - function renderPane(messages: ReturnType[]): void { + function renderPane( + messages: ReturnType[], + agent: 'claude' | 'codex' = 'claude' + ): void { mocks.submissions = messages.map((message) => message.submission) localStorage.setItem( `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, @@ -210,7 +213,7 @@ afterEach(() => { tabId="start-failure-tab" sessionId={SESSION_ID} target={{ kind: 'local' }} - agent="claude" + agent={agent} /> ) } @@ -268,4 +271,37 @@ afterEach(() => { expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() }) + + it.each([null, '0.100.0', '0.135.0'])( + 'states a missing or too-old Codex (installed version %s) in the existing failure notice', + async (installedVersion) => { + const failure: AgentSessionFailureFact = { + kind: installedVersion === null ? 'cliMissing' : 'startFailed', + ...(installedVersion === null + ? {} + : { + refusal: { + code: 'agent_session_operation_invalid', + details: { + reason: 'attachFailed', + codexInstallation: { installedVersion, minimumVersion: '0.136.0' } + } + } + }) + } + const { text } = agentSessionFailureWords(failure, { agentName: 'Codex', surface: 'row' }) + renderPane([rejected('codex-message', text, failure)], 'codex') + const row = await notice('codex-message') + expect(within(row).getByText(text)).toBeTruthy() + expect(text).toContain( + installedVersion === null + ? "Codex wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents." + : `Codex ${installedVersion} is too old for chats. Update to 0.136.0 or newer.` + ) + if (installedVersion !== null) { + expect(text).not.toContain('Settings → Agents') + } + expect(text).not.toContain('codex update') + } + ) } diff --git a/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts b/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts index a1ab8d3fe33..3e6250afda6 100644 --- a/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts @@ -18,6 +18,7 @@ type AvailabilityCopyId = Extract< | 'agentNotSignedIn' | 'thenSendAgain' | 'cliMissing' + | 'codexCliTooOld' > export const AVAILABILITY_PIECES: Record< @@ -69,5 +70,7 @@ export const AVAILABILITY_PIECES: Record< values ), cliMissing: (values) => - translate('components.native-chat.failureWords.cliMissing', COPY.cliMissing, values) + translate('components.native-chat.failureWords.cliMissing', COPY.cliMissing, values), + codexCliTooOld: (values) => + translate('components.native-chat.failureWords.codexCliTooOld', COPY.codexCliTooOld, values) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts index 97ef023dffb..4d7f65ab071 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-start-failure-facts.test.ts @@ -40,3 +40,26 @@ it('holds the same facts while the start rows state nothing new', () => { rerender({ items: [row, startFailureRow('other-start', { kind: 'providerStartFailed' })] }) expect(result.current).toBe(second) }) + +it('re-derives a changed Codex installation refusal instead of retaining its old facts', () => { + function failure(installedVersion: string | null): AgentSessionFailureFact { + return { + kind: 'startFailed', + refusal: { + code: 'agent_session_operation_invalid', + details: { + reason: 'attachFailed', + codexInstallation: { installedVersion, minimumVersion: '0.136.0' } + } + } + } + } + const { result, rerender } = renderHook( + ({ items }) => useStructuredAgentSessionStartFailureFacts(items, true), + { initialProps: { items: [startFailureRow('gen-1', failure(null))] } } + ) + const initial = result.current + rerender({ items: [startFailureRow('gen-1', failure('0.135.0'))] }) + expect(result.current).not.toBe(initial) + expect(result.current).toEqual([failure('0.135.0')]) +}) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 2f4af488fdc..d000a602d4b 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2864,6 +2864,7 @@ "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", "claudeSystemNotSignedIn": "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", "cliMissing": "{{agent}} wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents.", + "codexCliTooOld": "Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.", "codexManagedNotSignedIn": "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", "codexSystemNotSignedIn": "Codex isn't signed in. Run `{{loginCommand}}`.", "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c7413042f03..e7798c8734d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18054,6 +18054,7 @@ "sendAgainToTryOnceMore": "Send your message again to try once more.", "couldNotStart": "{{agent}} couldn't start.", "couldNotRestart": "{{agent}} couldn't restart.", + "codexCliTooOld": "Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.", "notSignedIn": "{{agent}} is not signed in.", "claudeSystemNotSignedIn": "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a76edbcff12..8906f2d8380 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17754,6 +17754,7 @@ "couldNotStart": "{{agent}} no pudo iniciarse.", "editSavedArguments": "Edítalos en Ajustes > Agentes > Argumentos.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", + "codexCliTooOld": "Codex {{installedVersion}} es demasiado antiguo para los chats. Actualiza a {{minimumVersion}} o una versión posterior.", "notSignedIn": "{{agent}} no ha iniciado sesión.", "claudeSystemNotSignedIn": "Claude no ha iniciado sesión. Ejecuta `{{loginCommand}}` o elige una cuenta en los ajustes de Cuentas de Claude.", "claudeManagedNotSignedIn": "Esta cuenta de Claude no ha iniciado sesión. Vuelve a iniciar sesión en la configuración de Cuentas de Claude.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0b3486d1f7b..b3b4b2fafdd 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17856,6 +17856,7 @@ "couldNotStart": "{{agent}} n'a pas pu démarrer.", "editSavedArguments": "Modifiez-les dans Paramètres > Agents > Arguments.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", + "codexCliTooOld": "Codex {{installedVersion}} est trop ancien pour les conversations. Mettez-le à jour vers {{minimumVersion}} ou une version ultérieure.", "notSignedIn": "{{agent}} n’est pas connecté.", "claudeSystemNotSignedIn": "Claude n’est pas connecté. Exécutez `{{loginCommand}}`, ou choisissez un compte dans les paramètres des Comptes Claude.", "claudeManagedNotSignedIn": "Ce compte Claude n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Claude.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a555dd5f7bd..a852837556d 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17789,6 +17789,7 @@ "couldNotStart": "{{agent}} を起動できませんでした。", "editSavedArguments": "設定 > Agent > 引数で編集してください。", "couldNotRestart": "{{agent}} を再起動できませんでした。", + "codexCliTooOld": "Codex {{installedVersion}} はチャットには古すぎます。{{minimumVersion}} 以降に更新してください。", "notSignedIn": "{{agent}} にサインインしていません。", "claudeSystemNotSignedIn": "Claude にサインインしていません。`{{loginCommand}}` を実行するか、Claude アカウント設定でアカウントを選択してください。", "claudeManagedNotSignedIn": "この Claude アカウントにサインインしていません。設定の Claude アカウントで再度サインインしてください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 834e4c442db..0e8217e2245 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17789,6 +17789,7 @@ "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", "editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", + "codexCliTooOld": "Codex {{installedVersion}}은 채팅에 사용하기에는 너무 오래된 버전입니다. {{minimumVersion}} 이상으로 업데이트하세요.", "notSignedIn": "{{agent}}에 로그인되어 있지 않습니다.", "claudeSystemNotSignedIn": "Claude에 로그인되어 있지 않습니다. `{{loginCommand}}`을 실행하거나 Claude 계정 설정에서 계정을 선택하세요.", "claudeManagedNotSignedIn": "이 Claude 계정에 로그인하지 않았습니다. Claude 계정 설정에서 다시 로그인하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bec53737a97..edfaed1a3b6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17768,6 +17768,7 @@ "couldNotStart": "{{agent}} 无法启动。", "editSavedArguments": "请在设置 > 智能体 > 参数中编辑。", "couldNotRestart": "{{agent}} 无法重新启动。", + "codexCliTooOld": "Codex {{installedVersion}} 版本过旧,无法用于聊天。请更新到 {{minimumVersion}} 或更高版本。", "notSignedIn": "{{agent}} 尚未登录。", "claudeSystemNotSignedIn": "Claude 尚未登录。请运行 `{{loginCommand}}`,或在 Claude 账户设置中选择账户。", "claudeManagedNotSignedIn": "此 Claude 账户尚未登录。请在 Claude 账户设置中重新登录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 47c5cde1e3f..48ba20afa52 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -5,6 +5,7 @@ import { CLAUDE_PROFILE_MISSING_MESSAGE, CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from './claude-profile-routing' +import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' /** Sentences a refusal notice shows too, so a chat says them one way. */ export const TERMINAL_AGENT_HOLDS_CHAT = 'This chat is still open in a terminal agent.' @@ -27,6 +28,8 @@ export const AGENT_SESSION_FAILURE_COPY = { sendAgainToTryOnceMore: 'Send your message again to try once more.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", + codexCliTooOld: + 'Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.', argumentsUnsupportedOption: 'Saved Arguments contain an unsupported option ({{option}}).', argumentsMissingValue: 'Saved Arguments need a value for {{option}}.', argumentsMultipleValues: 'Saved Arguments give {{option}} more than one value.', @@ -140,6 +143,8 @@ export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY /** What a piece's `{{name}}` placeholders stand for. */ export type AgentSessionFailureCopyValues = { + installedVersion?: string + minimumVersion?: string agent?: string command?: string loginCommand?: string @@ -158,6 +163,23 @@ export type AgentSessionFailureSay = ( values?: AgentSessionFailureCopyValues ) => string +export function codexInstallationFailureWords( + refusal: AgentSessionRefusalReference | undefined, + say: AgentSessionFailureSay +): string | null { + const installation = + refusal?.code === 'agent_session_operation_invalid' + ? refusal.details?.codexInstallation + : undefined + return installation + ? say(installation.installedVersion === null ? 'cliMissing' : 'codexCliTooOld', { + agent: 'Codex', + installedVersion: installation.installedVersion ?? '', + minimumVersion: installation.minimumVersion + }) + : null +} + /** The host's words, and any surface without translations. */ export const sayAgentSessionFailureEnglish: AgentSessionFailureSay = (id, values = {}) => { const filled = new Map(Object.entries(values)) diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index f73d3536cf2..9a0cbea46de 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -20,6 +20,7 @@ import { import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import { cliMissingSentence, notSignedInSentence } from './agent-session-availability-sentences' import { + codexInstallationFailureWords, sayAgentSessionFailureEnglish, type AgentSessionFailureCopyId, type AgentSessionFailureCopyValues, @@ -145,6 +146,10 @@ function startRetry( function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { return (context, fact, _surface, say) => { + const installationFailure = codexInstallationFailureWords(fact.refusal, say) + if (installationFailure) { + return installationFailure + } const failed = say(verb, agent(say, context)) if (fact.argumentProblem) { const problemCopy = { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index ae52282631a..747757b0b00 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -8,6 +8,10 @@ import type { AgentSessionAccountKind } from './agent-session-availability' import type { AgentJournalResolution } from './agent-session-journal-types' +import { + readCodexInstallationProblem, + type CodexInstallationProblem +} from './codex-cli-installation' import { readAgentSessionArgumentProblem, type AgentSessionArgumentProblem @@ -158,6 +162,7 @@ type AgentSessionRefusalFactsByCode = { agent_session_operation_invalid: RewindFacts & { account?: AgentSessionAccountKind argumentProblem?: AgentSessionArgumentProblem + codexInstallation?: CodexInstallationProblem } agent_session_operation_unknown: RewindFacts agent_session_checkpoint_stale: { @@ -279,6 +284,10 @@ export function readAgentSessionRefusalDetails 0 ? (read as AgentSessionRefusalDetails) : undefined diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index f81e1b51a90..ea124b0b74c 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -98,6 +98,16 @@ function reasonParts( write: AgentSessionWriteKind, context: AgentSessionFailureWordsContext ): AgentSessionWriteNoticePart[] | undefined { + if ( + failure.code === 'agent_session_operation_invalid' && + failure.details?.codexInstallation && + (write === 'send' || write === 'composer-send') + ) { + return [ + NOT_DONE[write], + { failure: { kind: 'startFailed', refusal: failure }, surface: 'rejection', context } + ] + } if ( failure.code === 'agent_session_operation_invalid' && failure.details?.argumentProblem && diff --git a/src/shared/agent-session-visible-failures.ts b/src/shared/agent-session-visible-failures.ts index 32a9cbce25f..ae00d1c083e 100644 --- a/src/shared/agent-session-visible-failures.ts +++ b/src/shared/agent-session-visible-failures.ts @@ -36,6 +36,12 @@ export function sameAgentSessionFailureFact( a.detail?.audience === b.detail?.audience && a.refusal?.code === b.refusal?.code && a.refusal?.details?.reason === b.refusal?.details?.reason && + (a.refusal?.code !== 'agent_session_operation_invalid' || + b.refusal?.code !== 'agent_session_operation_invalid' || + (a.refusal.details?.codexInstallation?.installedVersion === + b.refusal.details?.codexInstallation?.installedVersion && + a.refusal.details?.codexInstallation?.minimumVersion === + b.refusal.details?.codexInstallation?.minimumVersion)) && a.attachment?.reason === b.attachment?.reason && a.attachment?.limit === b.attachment?.limit && a.retry?.error === b.retry?.error && diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index 20dc9856390..cc35fd969ee 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -9,6 +9,7 @@ import { } from './agent-session-refusal-details' import type { AgentSessionRewindReason } from './agent-session-rewind' import type { AgentSessionArgumentProblem } from './agent-session-argument-problem' +import type { CodexInstallationProblem } from './codex-cli-installation' import { isAgentSessionWireRefusalCode, readAgentSessionRefusalReference, @@ -76,6 +77,7 @@ type DurableRefusalFacts = { rewindReason?: AgentSessionRewindReason account?: AgentSessionAccountKind argumentProblem?: AgentSessionArgumentProblem + codexInstallation?: CodexInstallationProblem } agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ @@ -83,7 +85,12 @@ type DurableRefusalFacts = { } const DURABLE_FACT_KEYS: Partial> = { - agent_session_operation_invalid: ['rewindReason', 'account', 'argumentProblem'], + agent_session_operation_invalid: [ + 'rewindReason', + 'account', + 'argumentProblem', + 'codexInstallation' + ], agent_session_operation_unknown: ['rewindReason'], agent_session_ownership_unknown: ['ownerVerdict'] } satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] } diff --git a/src/shared/codex-cli-installation.test.ts b/src/shared/codex-cli-installation.test.ts new file mode 100644 index 00000000000..0cebc319be3 --- /dev/null +++ b/src/shared/codex-cli-installation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_MINIMUM_SUPPORTED_VERSION, + codexCliInstallation, + readCodexInstallationProblem +} from './codex-cli-installation' + +describe('Codex structured chat version floor', () => { + it.each([ + ['codex-cli 0.135.0', 'unsupported'], + ['codex-cli 0.136.0', 'ready'], + ['codex-cli v0.136.0', 'ready'], + ['0.136.0-alpha.2', 'unsupported'], + ['0.136.0+build.2', 'ready'], + ['0.137.0-alpha.2', 'ready'], + ['codex-cli 0.153.4\n', 'ready'], + ['codex-cli 1.0.0', 'ready'], + ['codex-cli unknown', 'unknown'], + ['company-wrapper 0.1.0\ncodex-cli 0.136.0', 'ready'], + ['company-wrapper 9.0.0\ncodex-cli 0.135.0', 'unsupported'], + ['codex-cli 0.135.0\ncodex-cli 0.136.0', 'unknown'], + ['wrapper 0.135.0', 'unknown'], + ['0.135.0\n0.136.0', 'unknown'], + ['', 'unknown'] + ])('%s is %s', (output, status) => { + expect(codexCliInstallation(true, output)).toMatchObject({ + status, + minimumVersion: CODEX_MINIMUM_SUPPORTED_VERSION + }) + }) + + it('distinguishes an absent binary from an unverifiable version', () => { + expect(codexCliInstallation(false, null)).toMatchObject({ status: 'missing', version: null }) + expect(codexCliInstallation(true, null)).toMatchObject({ status: 'unknown', version: null }) + }) + + it('admits only version facts that can safely be put in the refusal sentence', () => { + const problem = { installedVersion: '0.135.0', minimumVersion: '0.136.0' } + expect(readCodexInstallationProblem(problem)).toEqual(problem) + expect(readCodexInstallationProblem({ ...problem, installedVersion: null })).toEqual({ + ...problem, + installedVersion: null + }) + expect( + readCodexInstallationProblem({ ...problem, installedVersion: '