feat(native-chat): require Codex 0.136.0 for chats (#26684)

* Require Codex 0.136.0 for native chats

* Add host-owned Codex install and update actions

* Bind Codex checks and maintenance to the selected installation

* Resolve Windows updater cleanup through system helpers

* Use one child environment for Codex checks and launch

* Expire Codex readiness and reconcile current host jobs

* Resolve Codex checks against the child command search path

* Restrict global npm repair to the selected Codex install layout

* Pin the host updater directory through Windows supervision

* Synchronize Codex maintenance fixtures on readiness

* Unify Codex notices and recover version-refused chats

* Route Codex recovery to the operation that remains

* Preserve Codex setup execution errors in the job log

* Preserve UTF-8 in Codex setup logs

* Remove unused Codex host installation detector

* Require host administration for Codex maintenance

* Show a missing Codex explanation once

* test(native-chat): give scripted Codex runtimes their installation fact; keep the registry out of the relay

Runtime tests that drive a scripted Codex app-server still ran the new
version check against whatever codex was on the machine's PATH: refused
on CI (no codex), and on a dev machine it ran the real codex --version.
A scripted transport now must state the Codex it stands for
(readCodexInstallation), asserted at install, and the tests get it from
one scriptedCodexTransport fixture.

The relay's Codex maintenance path reached @orca/windows-registry through
resolvePathEnvKey's re-export in windows-environment-path; import it from
its registry-free module instead.

Also update the catalog-probe test for the shared child environment.

* refactor(native-chat): drop the Update Codex action; keep Install for a missing Codex

A too-old Codex now gets only the plain message ("Codex X is too old for
chats. Update to 0.136.0 or newer.") with no button and no job. The
existing rechecks (window focus, the <=30 s expiry of a saved result)
retry the refused chat once the user has updated Codex themselves.

A missing Codex keeps the Install Codex button: `npm install -g
@openai/codex` with the npm on the chat's PATH, as a host-run job with
its log, then a recheck and recovery. A configured Command that is
missing gets the message only, since a global npm install would not
land at that path.

Removed: the `codex update` command and its 0.126 floor, manual
instructions, npm layout and launcher identity detection, the action
descriptor (install|update) and its titles, the legacy capability, and
the SSH relay maintenance path (structured chat never runs over SSH or
WSL), so the relay preflight handler is back to main's.

* refactor(native-chat): keep Install Codex in Settings only

When Codex isn't installed, main already hides Codex from the New tab menu,
so the in-chat Install button was almost never reachable. Settings → Agents
already shows "Codex isn't installed." with Install Codex.

- Chats and the new-workspace composer now only state "Codex isn't
  installed." (no button, no install job from the chat).
- The Install action, its busy spinner and the host-busy subscription move
  into the Settings Codex row, the only place that starts the job.
- The store no longer re-checks every other workspace context when a job
  finishes. A refused chat recovers through the same re-check a too-old
  Codex uses (window focus, or the <=30 s expiry of a saved result).
- The chat recovery key no longer includes the install job id.
- Tests: Settings drives the install; a new test proves a chat refused for
  a missing Codex recovers through focus after a Settings install, not
  through the job; the expiry recovery test covers missing and too old.

* refactor(codex): check chat compatibility when acquiring the child

* Restore workspace submit guard and simplify Codex maintenance

* Keep only the Codex chat minimum version
This commit is contained in:
Brennan Benson
2026-10-12 14:30:27 -07:00
committed by GitHub
parent 6af34a4d99
commit c0e2b73395
46 changed files with 1003 additions and 152 deletions
+21 -10
View File
@@ -18,6 +18,22 @@ export async function probeAgentCliVersion(
input: Pick<ProcessSpec, 'program' | 'cwd' | 'env'>,
supports: (version: string) => boolean
): Promise<boolean> {
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<ProcessSpec, 'program' | 'cwd' | 'env'>,
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. */
@@ -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<typeof readCodexCliInstallation>[0],
readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation
): Promise<void> {
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<L extends ProviderProcessLaunch>(
resolveLaunch: StructuredAgentLaunchResolver<L>,
readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation
): StructuredAgentLaunchResolver<L> {
return async (request) => {
const launch = await resolveLaunch(request)
await requireSupportedCodexCli(
{
program: launch.command,
cwd: launch.cwd,
env: resolveProviderChildEnv(launch, process.env)
},
readInstallation
)
return launch
}
}
@@ -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()
}
)
})
@@ -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<string, unknown>) => {
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<string, unknown>) => {
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(
+5 -36
View File
@@ -28,41 +28,6 @@ function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value : null
}
const resumeMetadataUnsupported = new WeakSet<object>()
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<CodexAppServerConnection, 'request'>,
params: Record<string, unknown>,
timeoutMs: number | undefined
): Promise<unknown> {
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
@@ -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()
})
@@ -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,
@@ -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
)
@@ -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<object>()),
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')
}
)
})
@@ -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<ProcessSpec, 'program' | 'cwd' | 'env'>
): Promise<CodexCliInstallation> {
const result = await readAgentCliVersion(input, parseCodexCliVersion)
return codexCliInstallation(true, result.version)
}
@@ -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<typeof CommandResolution>()
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')
})
})
@@ -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<StructuredAgentSessionRuntimeDeps['openCodexConnection']>
): Pick<
StructuredAgentSessionRuntimeDeps,
'openCodexConnection' | 'readCodexInstallation' | 'readProcessStartTime'
> {
return {
openCodexConnection: openConnection,
readCodexInstallation: async () => codexCliInstallation(true, CODEX_MINIMUM_SUPPORTED_VERSION),
readProcessStartTime: async () => 1_700_000_000_000
}
}
@@ -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<Agent, string> = {
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',
@@ -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 } : {}),
@@ -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()
@@ -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()
@@ -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()
@@ -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<void> {
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
resolveCodexCommand: () => 'codex',
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
openCodexConnection: openConnection,
readProcessStartTime: async () => 1_700_000_000_000
...scriptedCodexTransport(openConnection)
})
}
@@ -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()
@@ -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
@@ -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()
@@ -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']
@@ -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)
@@ -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 })
@@ -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: () => {},
@@ -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<string, string> =>
agent === 'codex' ? { CODEX_PROFILE: 'configured' } : {},
openCodexConnection: codex.openConnection,
readProcessStartTime: async () => 1_700_000_000_000
...scriptedCodexTransport(codex.openConnection)
})
const runtime = {
getRuntimeId: () => 'runtime-1',
@@ -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),
@@ -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()
}
)
})
@@ -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<typeof rejected>[]): void {
function renderPane(
messages: ReturnType<typeof rejected>[],
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')
}
)
}
@@ -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)
}
@@ -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')])
})
+1
View File
@@ -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}}.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -17789,6 +17789,7 @@
"couldNotStart": "{{agent}} を起動できませんでした。",
"editSavedArguments": "設定 > Agent > 引数で編集してください。",
"couldNotRestart": "{{agent}} を再起動できませんでした。",
"codexCliTooOld": "Codex {{installedVersion}} はチャットには古すぎます。{{minimumVersion}} 以降に更新してください。",
"notSignedIn": "{{agent}} にサインインしていません。",
"claudeSystemNotSignedIn": "Claude にサインインしていません。`{{loginCommand}}` を実行するか、Claude アカウント設定でアカウントを選択してください。",
"claudeManagedNotSignedIn": "この Claude アカウントにサインインしていません。設定の Claude アカウントで再度サインインしてください。",
+1
View File
@@ -17789,6 +17789,7 @@
"couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.",
"editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.",
"couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.",
"codexCliTooOld": "Codex {{installedVersion}}은 채팅에 사용하기에는 너무 오래된 버전입니다. {{minimumVersion}} 이상으로 업데이트하세요.",
"notSignedIn": "{{agent}}에 로그인되어 있지 않습니다.",
"claudeSystemNotSignedIn": "Claude에 로그인되어 있지 않습니다. `{{loginCommand}}`을 실행하거나 Claude 계정 설정에서 계정을 선택하세요.",
"claudeManagedNotSignedIn": "이 Claude 계정에 로그인하지 않았습니다. Claude 계정 설정에서 다시 로그인하세요.",
+1
View File
@@ -17768,6 +17768,7 @@
"couldNotStart": "{{agent}} 无法启动。",
"editSavedArguments": "请在设置 > 智能体 > 参数中编辑。",
"couldNotRestart": "{{agent}} 无法重新启动。",
"codexCliTooOld": "Codex {{installedVersion}} 版本过旧,无法用于聊天。请更新到 {{minimumVersion}} 或更高版本。",
"notSignedIn": "{{agent}} 尚未登录。",
"claudeSystemNotSignedIn": "Claude 尚未登录。请运行 `{{loginCommand}}`,或在 Claude 账户设置中选择账户。",
"claudeManagedNotSignedIn": "此 Claude 账户尚未登录。请在 Claude 账户设置中重新登录。",
+22
View File
@@ -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))
@@ -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 = {
+11 -1
View File
@@ -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<C extends AgentSessionWireRefusal
code === 'agent_session_operation_invalid'
? readAgentSessionArgumentProblem(value.argumentProblem)
: undefined
const codexInstallation =
code === 'agent_session_operation_invalid'
? readCodexInstallationProblem(value.codexInstallation)
: undefined
const read = {
...(isAgentSessionRefusalReason(code, value.reason) ? { reason: value.reason } : {}),
...facts,
@@ -287,7 +296,8 @@ export function readAgentSessionRefusalDetails<C extends AgentSessionWireRefusal
(value.account === 'managed' || value.account === 'system')
? { account: value.account }
: {}),
...(argumentProblem ? { argumentProblem } : {})
...(argumentProblem ? { argumentProblem } : {}),
...(codexInstallation ? { codexInstallation } : {})
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reason was checked against `code`'s list and only the facts `code` lists (plus the verdict every code may carry) were kept.
return Object.keys(read).length > 0 ? (read as AgentSessionRefusalDetails<C>) : undefined
@@ -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 &&
@@ -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 &&
+8 -1
View File
@@ -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<Record<AgentSessionWireRefusalCode, readonly string[]>> = {
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])[] }
+49
View File
@@ -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: '<script>' })
).toBeUndefined()
expect(readCodexInstallationProblem({ ...problem, minimumVersion: '' })).toBeUndefined()
})
})
+61
View File
@@ -0,0 +1,61 @@
import { compareAppVersions, isValidAppVersion, parseCliVersion } from './app-version'
export const CODEX_MINIMUM_SUPPORTED_VERSION = '0.136.0'
export type CodexCliInstallation = {
status: 'missing' | 'unsupported' | 'ready' | 'unknown'
version: string | null
minimumVersion: string
}
export type CodexInstallationProblem = {
installedVersion: string | null
minimumVersion: string
}
export function parseCodexCliVersion(output: string | null | undefined): string | null {
const lines = output?.split(/\r?\n/).map((line) => line.trim()) ?? []
const identified = lines.filter((line) => /^codex(?:-cli)?\s/i.test(line))
const candidates = identified.length ? identified : lines.filter(Boolean)
const versions = candidates.map((line) => {
const value = line.replace(/^codex(?:-cli)?\s+/i, '').replace(/^v/i, '')
return isValidAppVersion(value) ? parseCliVersion(value) : null
})
return versions.length && versions.every((version) => version !== null && version === versions[0])
? versions[0]
: null
}
export function codexCliInstallation(
installed: boolean,
output: string | null
): CodexCliInstallation {
const version = parseCodexCliVersion(output)
return {
status: !installed
? 'missing'
: version === null
? 'unknown'
: compareAppVersions(version, CODEX_MINIMUM_SUPPORTED_VERSION) < 0
? 'unsupported'
: 'ready',
version,
minimumVersion: CODEX_MINIMUM_SUPPORTED_VERSION
}
}
export function readCodexInstallationProblem(value: unknown): CodexInstallationProblem | undefined {
if (
typeof value !== 'object' ||
value === null ||
!('installedVersion' in value) ||
!('minimumVersion' in value) ||
(value.installedVersion !== null &&
(typeof value.installedVersion !== 'string' || !isValidAppVersion(value.installedVersion))) ||
typeof value.minimumVersion !== 'string' ||
!isValidAppVersion(value.minimumVersion)
) {
return undefined
}
return { installedVersion: value.installedVersion, minimumVersion: value.minimumVersion }
}