mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
+107
@@ -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()
|
||||
})
|
||||
+7
-1
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+38
-2
@@ -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)
|
||||
}
|
||||
|
||||
+23
@@ -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
@@ -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}}.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -17789,6 +17789,7 @@
|
||||
"couldNotStart": "{{agent}} を起動できませんでした。",
|
||||
"editSavedArguments": "設定 > Agent > 引数で編集してください。",
|
||||
"couldNotRestart": "{{agent}} を再起動できませんでした。",
|
||||
"codexCliTooOld": "Codex {{installedVersion}} はチャットには古すぎます。{{minimumVersion}} 以降に更新してください。",
|
||||
"notSignedIn": "{{agent}} にサインインしていません。",
|
||||
"claudeSystemNotSignedIn": "Claude にサインインしていません。`{{loginCommand}}` を実行するか、Claude アカウント設定でアカウントを選択してください。",
|
||||
"claudeManagedNotSignedIn": "この Claude アカウントにサインインしていません。設定の Claude アカウントで再度サインインしてください。",
|
||||
|
||||
@@ -17789,6 +17789,7 @@
|
||||
"couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.",
|
||||
"editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.",
|
||||
"couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.",
|
||||
"codexCliTooOld": "Codex {{installedVersion}}은 채팅에 사용하기에는 너무 오래된 버전입니다. {{minimumVersion}} 이상으로 업데이트하세요.",
|
||||
"notSignedIn": "{{agent}}에 로그인되어 있지 않습니다.",
|
||||
"claudeSystemNotSignedIn": "Claude에 로그인되어 있지 않습니다. `{{loginCommand}}`을 실행하거나 Claude 계정 설정에서 계정을 선택하세요.",
|
||||
"claudeManagedNotSignedIn": "이 Claude 계정에 로그인하지 않았습니다. Claude 계정 설정에서 다시 로그인하세요.",
|
||||
|
||||
@@ -17768,6 +17768,7 @@
|
||||
"couldNotStart": "{{agent}} 无法启动。",
|
||||
"editSavedArguments": "请在设置 > 智能体 > 参数中编辑。",
|
||||
"couldNotRestart": "{{agent}} 无法重新启动。",
|
||||
"codexCliTooOld": "Codex {{installedVersion}} 版本过旧,无法用于聊天。请更新到 {{minimumVersion}} 或更高版本。",
|
||||
"notSignedIn": "{{agent}} 尚未登录。",
|
||||
"claudeSystemNotSignedIn": "Claude 尚未登录。请运行 `{{loginCommand}}`,或在 Claude 账户设置中选择账户。",
|
||||
"claudeManagedNotSignedIn": "此 Claude 账户尚未登录。请在 Claude 账户设置中重新登录。",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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])[] }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user