diff --git a/src/main/acp/acp-launch-specs.ts b/src/main/acp/acp-launch-specs.ts index 1770448230f..dafa3824532 100644 --- a/src/main/acp/acp-launch-specs.ts +++ b/src/main/acp/acp-launch-specs.ts @@ -12,11 +12,11 @@ import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-accou import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' import { openCodeStoredUserMessagesReader } from '../opencode/opencode-acp-stored-messages' import type { AcpStoredUserMessagesReader } from './acp-recovery-history' -import { isStableCliVersionFrom, isStableCliVersionOnLine } from '../agent-cli-version-probe' import type { TuiAgent } from '../../shared/tui-agent' import type { AgentSessionModelOption } from '../../shared/agent-session-wire' import type { AgentChatPermissionMode } from '../../shared/agent-chat-permission-mode' import type { InitializeResponse } from './generated/acp-protocol.generated' +import { compareAppVersions } from '../../shared/app-version' import type { ManagedRuntime } from '../node-runtime/managed-runtime-release' import { readGrokModelCatalog } from './acp-dialects/grok-model-catalog' import { @@ -134,14 +134,14 @@ export type AcpLaunchSpec = { signIn?: Omit /** The account each chat pins, and how a launch points the agent at it. */ account: AcpAccountBinding - /** The `--version` releases a structured chat runs on, asked before a create and again at every - * launch; any other release keeps the terminal chat. Absent runs whatever is installed. */ - supportsVersion?(version: string): boolean + /** The ascending `--version` minimums a structured chat needs (`agentCliMinimumVersion`), asked + * before a create and again at every launch; absent runs whatever is installed. */ + minimumVersions?: readonly string[] /** Where the agent installs its own binary, searched after PATH. */ installDirectories(input: { env: Readonly>; homePath: string }): string[] /** A pinned copy Orca installs under its data folder when the person asks; a start runs an * installed release before PATH and never installs one. A spec with one names no - * `supportsVersion`. */ + * `minimumVersions`. */ managedRuntime?: ManagedRuntime /** The agent's `mode` config option value per chat permission mode, through which it enforces * the mode itself; Orca then answers none of its permission requests for the person. */ @@ -207,15 +207,14 @@ const GROK_LAUNCH_SPEC: AcpLaunchSpec = { sessionHistory: GROK_SESSION_HISTORY } -// The release the recorded sessions capture. -const OPENCODE_1X_LINE = { major: 1, floor: '1.18.31' } as const -// The 2.x release verified to start the private child below. -const OPENCODE_2X_LINE = { major: 2, floor: '2.0.14' } as const +// 1.18.31 is the release the recorded sessions capture; 2.0.14 the 2.x release verified to start the +// private child below. +const OPENCODE_MINIMUM_VERSIONS = ['1.18.31', '2.0.14'] as const // `opencode acp` on 1.x serves in-process; on 2.x it starts a private `opencode serve --stdio` child // with this environment and ends it with stdin, so both lines run the chat's own account. function openCodeLaunchSpec( - row: Pick + row: Pick ): AcpLaunchSpec { return { ...row, @@ -243,8 +242,7 @@ function openCodeLaunchSpec( const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({ agent: 'opencode', command: 'opencode', - supportsVersion: (version) => - [OPENCODE_1X_LINE, OPENCODE_2X_LINE].some((line) => isStableCliVersionOnLine(version, line)), + minimumVersions: OPENCODE_MINIMUM_VERSIONS, // Its `initialize` names no models; 1.x lists them with a command, 2.x in a session it deletes. modelDiscovery: { kind: 'command', @@ -252,8 +250,8 @@ const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({ parse: parseOpenCodeModelListing, // The listing marks no default; a chat started with no pick names it. listingNamesConfiguredModel: false, - // 2.x rejects `--verbose`, and its own listings omit providers that report after startup. - supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_1X_LINE), + // 2.x and later reject `--verbose`, and their listings omit providers that report after startup. + supportsVersion: (version) => compareAppVersions(version, '2.0.0') < 0, otherReleasesListInSession: true } }) @@ -263,7 +261,7 @@ const OPENCODE_LAUNCH_SPEC = openCodeLaunchSpec({ const OPENCODE2_LAUNCH_SPEC = openCodeLaunchSpec({ agent: 'opencode2', command: 'opencode2', - supportsVersion: (version) => isStableCliVersionOnLine(version, OPENCODE_2X_LINE), + minimumVersions: ['2.0.14'], modelDiscovery: { kind: 'session', listingNamesConfiguredModel: false } }) @@ -282,8 +280,8 @@ const OMP_LAUNCH_SPEC: AcpLaunchSpec = { ), // OMP's installers use directories the shared resolver already searches after PATH. installDirectories: () => [], - // Stable releases from 17.0.5, the release verified to serve `omp acp`. - supportsVersion: (version) => isStableCliVersionFrom(version, '17.0.5'), + // 17.0.5 is the release verified to serve `omp acp`. + minimumVersions: ['17.0.5'], // `omp models --json` lists every model without the `enabledModels` filter a chat applies, so it // could offer models a chat refuses. A session lists exactly what a chat in that folder offers; // `omp acp` cannot delete one, so it is kept out of OMP's store (17.0.5 and 18.12.2 verified). diff --git a/src/main/acp/acp-model-catalog-probe.test.ts b/src/main/acp/acp-model-catalog-probe.test.ts index 073c889cedf..65bfc4cddb9 100644 --- a/src/main/acp/acp-model-catalog-probe.test.ts +++ b/src/main/acp/acp-model-catalog-probe.test.ts @@ -140,7 +140,7 @@ function scriptedOpenCode2(canDelete: boolean): { deps: { resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), resolveCommand: () => '/opt/opencode/bin/opencode', - probeVersion: async (_launch, supports) => supports('2.0.29'), + probeVersion: async () => ({ version: '2.0.29' }), homePath: '/home/user', dataDirectory: '/orca-data', runListing: vi.fn>(), @@ -229,7 +229,7 @@ describe('ACP model catalog probes', () => { resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/user' }), dataDirectory: '/orca-data', resolveCommand: () => '/opt/opencode/bin/opencode', - probeVersion: async (_launch, supports) => supports('1.18.35'), + probeVersion: async () => ({ version: '1.18.35' }), homePath: '/home/user', runListing, connect @@ -297,7 +297,7 @@ describe('ACP model catalog probes', () => { ...deps, dataDirectory, resolveCommand: () => '/opt/omp/bin/omp', - probeVersion: async (_launch, supports) => supports('18.12.2') + probeVersion: async () => ({ version: '18.12.2' }) })( { variable: 'PI_CODING_AGENT_DIR', path: '/homes/omp' }, { workspacePath: '/work/project' } @@ -387,7 +387,7 @@ describe('ACP model catalog probes', () => { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), dataDirectory: '/orca-data', resolveCommand: () => '/opt/opencode/bin/opencode', - probeVersion: async () => false, + probeVersion: async () => ({ version: '1.0.0' }), runListing }) await expect( @@ -401,7 +401,7 @@ describe('ACP model catalog probes', () => { // The binary may be 2.x, which lists only in a chat's folder; nothing is spawned to find out. it('asks `opencode` nothing, not even its version, without a chat folder', async () => { - const probeVersion = vi.fn(async () => true) + const probeVersion = vi.fn(async () => ({ version: '1.18.35' })) const probe = createAcpModelCatalogProbe(OPENCODE, { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), dataDirectory: '/orca-data', diff --git a/src/main/acp/acp-model-catalog-probe.ts b/src/main/acp/acp-model-catalog-probe.ts index e619e1e64af..dd96eef0b2e 100644 --- a/src/main/acp/acp-model-catalog-probe.ts +++ b/src/main/acp/acp-model-catalog-probe.ts @@ -1,6 +1,6 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { probeAgentCliVersion } from '../agent-cli-version-probe' +import type { AgentCliVersionReader } from '../agent-cli-version-probe' import type { AgentModelCatalogProbe, AgentModelCatalogSuccess @@ -22,7 +22,7 @@ import { } from './acp-launch-specs' import type { AcpSessionStarted, AcpSessionStartOptions } from './acp-session-setup' import { - probeAcpLaunchVersion, + checkAcpLaunchVersion, resolveAcpLaunchInvocation, type AcpLaunchInvocationDeps } from './acp-structured-launch-resolution' @@ -47,7 +47,7 @@ export type AcpModelCatalogSessionConnection = Omit< export type AcpModelCatalogProbeDeps = AcpLaunchInvocationDeps & { /** Test seams; production spawns through the shared supervised runner and ACP connection. */ - probeVersion?: typeof probeAgentCliVersion + probeVersion?: AgentCliVersionReader connect?: (launch: ProviderProcessLaunch) => AcpModelCatalogConnection connectSession?: (launch: ProviderProcessLaunch) => AcpModelCatalogSessionConnection runListing?: typeof runAgentModelCatalogListing @@ -79,13 +79,13 @@ export function createAcpModelCatalogProbe( ...deps, resolveWorkingDirectory: () => cwd }) - const release = await probeAcpLaunchVersion( + const release = await checkAcpLaunchVersion( spec, { command, cwd, env }, discovery.kind === 'command' && discovery.supportsVersion !== undefined, - deps.probeVersion ?? probeAgentCliVersion + deps.probeVersion ) - if (!release.supported) { + if (release.status === 'tooOld') { throw new Error(`${spec.agent} at ${command} does not run structured chats`) } const listing = listingFor(discovery, release.version) diff --git a/src/main/acp/acp-scripted-child.test-fixture.ts b/src/main/acp/acp-scripted-child.test-fixture.ts index c81d0ce4709..f8f2ccd882b 100644 --- a/src/main/acp/acp-scripted-child.test-fixture.ts +++ b/src/main/acp/acp-scripted-child.test-fixture.ts @@ -31,7 +31,7 @@ export function acpScriptedChild(agent: AcpLaunchSpec['agent']): ScriptedAgentCh resolveEnvironment: async () => ({ PATH: '' }), acpLaunchCommand: { resolveCommand: () => 'scripted-acp-agent', - probeVersion: async () => true + probeVersion: async () => ({ version: '99.0.0' }) }, readProcessStartTime: async () => 1_700_000_000_000 + opened, openAcpConnection: (launch, options) => { diff --git a/src/main/acp/acp-structured-context-clear.test.ts b/src/main/acp/acp-structured-context-clear.test.ts index e3177ec37a1..792dbdab7d3 100644 --- a/src/main/acp/acp-structured-context-clear.test.ts +++ b/src/main/acp/acp-structured-context-clear.test.ts @@ -40,7 +40,7 @@ it.each(ACP_LAUNCH_SPECS)( resolveWorkspacePath: async () => '/repo/worktree', resolveEnvironment: async () => ({ PATH: '/usr/bin', HOME: '/home/test' }), resolveCommand: () => `/fixture/${spec.command}`, - probeVersion: async () => true, + probeVersion: async () => ({ version: null }), inheritedEnv: {} } ) diff --git a/src/main/acp/acp-structured-launch-resolution-omp.test.ts b/src/main/acp/acp-structured-launch-resolution-omp.test.ts index 6a5b79f85b3..0f34bd39f99 100644 --- a/src/main/acp/acp-structured-launch-resolution-omp.test.ts +++ b/src/main/acp/acp-structured-launch-resolution-omp.test.ts @@ -43,7 +43,7 @@ function resolver( resolveLaunchEnv: () => options.launchEnv ?? {}, resolveCommand: (command) => `/resolved/${command}`, inheritedEnv: {}, - probeVersion: options.probeVersion ?? (async (_input, supports) => supports('17.0.5')) + probeVersion: options.probeVersion ?? (async () => ({ version: '17.0.5' })) }) } @@ -100,32 +100,15 @@ describe('OMP ACP launch resolution', () => { ).rejects.toThrow(/pin PI_CODING_AGENT_DIR/) }) - it.each(['17.0.5', '17.2.12', '18.4.5'])('admits OMP %s', async (version) => { - const launch = resolver(ompRecord(), { - probeVersion: async (_input, supports) => supports(version) - }) + it.each(['17.0.5', '17.2.12', '18.4.5', '18.0.0-beta.1'])('admits OMP %s', async (version) => { + const launch = resolver(ompRecord(), { probeVersion: async () => ({ version }) }) await expect(launch({ identity })).resolves.toMatchObject({ args: ['acp'] }) }) - it.each(['17.0.4', '16.9.0', '18.0.0-beta.1'])( - 'refuses OMP %s before spawning it, as a host that cannot run the chat', - async (version) => { - const launch = resolver(ompRecord(), { - probeVersion: async (_input, supports) => supports(version) - }) - await expect(launch({ identity })).rejects.toMatchObject({ - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - }) - } - ) - - it('refuses when the version cannot be read', async () => { - const launch = resolver(ompRecord(), { probeVersion: async () => false }) + it.each(['17.0.4', '16.9.0'])('refuses OMP %s before spawning it as too old', async (version) => { + const launch = resolver(ompRecord(), { probeVersion: async () => ({ version }) }) await expect(launch({ identity })).rejects.toMatchObject({ - refusal: { code: 'structured_agent_session_unsupported' } + installation: { agent: 'omp', installedVersion: version, minimumVersion: '17.0.5' } }) }) @@ -134,7 +117,7 @@ describe('OMP ACP launch resolution', () => { NonNullable[1]['probeVersion']> >[0][] = [] const launch = await resolver(ompRecord(), { - probeVersion: async (input, supports) => (asked.push(input), supports('17.0.5')) + probeVersion: async (input) => (asked.push(input), { version: '17.0.5' }) })({ identity }) expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) }) diff --git a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts index da7a3a5da84..ce39a01e87a 100644 --- a/src/main/acp/acp-structured-launch-resolution-opencode.test.ts +++ b/src/main/acp/acp-structured-launch-resolution-opencode.test.ts @@ -9,6 +9,7 @@ import { openCodeAcpAccountBinding } from '../opencode/opencode-structured-accou import { scrubOpenCodeAcpEnvironment } from '../opencode/opencode-acp-environment' import { acpLaunchSpecFor, type AcpLaunchSpec } from './acp-launch-specs' import { STRUCTURED_CHILD_ENV_TO_DELETE } from '../runtime/structured-session-child-env' +import { AgentCliInstallationError } from '../native-chat/agent-cli-installation-error' const PROFILE = '123e4567-e89b-42d3-a456-426614174000' @@ -71,7 +72,7 @@ function resolver( resolveLaunchEnv: () => options.launchEnv ?? {}, resolveCommand: (command) => `/resolved/${command}`, inheritedEnv: options.inheritedEnv ?? {}, - probeVersion: options.probeVersion ?? (async (_input, supports) => supports('1.18.31')) + probeVersion: options.probeVersion ?? (async () => ({ version: '1.18.31' })) }) } @@ -217,30 +218,28 @@ describe('OpenCode ACP launch resolution', () => { ).rejects.toThrow(/sessions pin opencode/) }) - it('runs an `opencode` that is stable 2.x', async () => { + it.each(['2.0.21', '3.0.0', '3.0.0-beta.1'])('runs an `opencode` %s', async (version) => { const launch = await resolver(openCodeRecord(UNMANAGED), { - probeVersion: async (_input, supports) => supports('2.0.21') + probeVersion: async () => ({ version }) })({ identity }) expect(launch.args).toEqual(['acp']) }) - it('refuses a release outside its lines before spawning it, as a host that cannot run the chat', async () => { - const launch = resolver(openCodeRecord(UNMANAGED), { - probeVersion: async (_input, supports) => supports('3.0.0') + it('runs when the version cannot be read; any real start failure reports itself', async () => { + const launch = await resolver(openCodeRecord(UNMANAGED), { + probeVersion: async () => ({ version: null }) })({ identity }) - await expect(launch).rejects.toMatchObject({ - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - }) + expect(launch.args).toEqual(['acp']) }) - it('refuses when the version cannot be read', async () => { - const launch = resolver(openCodeRecord(UNMANAGED), { probeVersion: async () => false }) - await expect(launch({ identity })).rejects.toMatchObject({ - refusal: { code: 'structured_agent_session_unsupported' } - }) + it('refuses a too-old release before spawning it, naming OpenCode and both versions', async () => { + const launch = resolver(openCodeRecord(UNMANAGED), { + probeVersion: async () => ({ version: '1.0.0' }) + })({ identity }) + await expect(launch).rejects.toBeInstanceOf(AgentCliInstallationError) + await expect(launch).rejects.toThrow( + 'OpenCode 1.0.0 is too old for chats. Update to 1.18.31 or newer.' + ) }) it('asks the version of exactly the binary, folder and environment it spawns', async () => { @@ -248,7 +247,7 @@ describe('OpenCode ACP launch resolution', () => { NonNullable[1]['probeVersion']> >[0][] = [] const launch = await resolver(openCodeRecord(UNMANAGED), { - probeVersion: async (input, supports) => (asked.push(input), supports('1.18.31')) + probeVersion: async (input) => (asked.push(input), { version: '1.18.31' }) })({ identity }) expect(asked).toEqual([{ program: launch.command, cwd: launch.cwd, env: launch.env }]) }) @@ -263,7 +262,7 @@ describe('OpenCode 2 ACP launch resolution', () => { const launchOn = (version: string) => resolver( { ...openCodeRecord(UNMANAGED), provider: 'opencode2' }, - { spec: OPENCODE2, probeVersion: async (_input, supports) => supports(version) } + { spec: OPENCODE2, probeVersion: async () => ({ version }) } )({ identity: { ...identity, agent: 'opencode2' } }) it('runs `opencode2 acp` as an ACP client, and names its own sign-in command', async () => { @@ -273,16 +272,16 @@ describe('OpenCode 2 ACP launch resolution', () => { expect(OPENCODE2.loginCommand).toEqual(['opencode2', 'auth', 'login']) }) - // 1.x, below the 2.x floor, and a pre-release: none is the OpenCode 2 this row runs. - it.each(['1.18.35', '2.0.13', '2.1.0-beta.1'])( - 'refuses %s before spawning it', - async (version) => { - await expect(launchOn(version)).rejects.toMatchObject({ - refusal: { - code: 'structured_agent_session_unsupported', - details: { reason: 'hostUnsupported' } - } - }) - } - ) + it('runs a later major or a pre-release of one', async () => { + await expect(launchOn('3.0.0-beta.1')).resolves.toMatchObject({ + command: '/resolved/opencode2' + }) + }) + + // 1.x and 2.x below 2.0.14 never ran the private child this row needs. + it.each(['1.18.35', '2.0.13', '2.0.14-rc.1'])('refuses %s as too old', async (version) => { + await expect(launchOn(version)).rejects.toMatchObject({ + installation: { agent: 'opencode2', installedVersion: version, minimumVersion: '2.0.14' } + }) + }) }) diff --git a/src/main/acp/acp-structured-launch-resolution.ts b/src/main/acp/acp-structured-launch-resolution.ts index d0ddf202dcd..1403ad810d6 100644 --- a/src/main/acp/acp-structured-launch-resolution.ts +++ b/src/main/acp/acp-structured-launch-resolution.ts @@ -14,13 +14,17 @@ import { } from '../../shared/agent-session-provider-handle' import type { AgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' -import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { resolveAgainAfterRefreshOnMissingProgram, resolveStructuredAgentCommand, type StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' -import { probeAgentCliVersion } from '../agent-cli-version-probe' +import { + checkAgentCliVersion, + type AgentCliVersionCheck, + type AgentCliVersionReader +} from '../agent-cli-version-probe' +import { AgentCliInstallationError } from '../native-chat/agent-cli-installation-error' import { copyBesidePinnedMembers, resolveInstalledRuntimeRelease @@ -75,7 +79,7 @@ export type AcpStructuredLaunchPartDeps = { homePath?: string /** The environment the child process inherits at spawn; this process's own by default. */ inheritedEnv?: NodeJS.ProcessEnv - probeVersion?: typeof probeAgentCliVersion + probeVersion?: AgentCliVersionReader /** Orca's data folder on this host, which holds the spec's managed runtime. */ dataDirectory: string } & Pick @@ -141,37 +145,22 @@ export function resolveAcpLaunchCommand( return (found !== spec.command && copyBesidePinnedMembers(found, pin)) || spec.command } -/** Whether `command` is a release the spec runs a structured chat on (true when it names none), - * and the version it printed. Asked only when the spec names releases or `wantVersion`. */ -export async function probeAcpLaunchVersion( +/** What `command`'s `--version` says against the spec's minimums; asked only when the spec names + * some or `wantVersion`. */ +export async function checkAcpLaunchVersion( spec: AcpLaunchSpec, launch: { command: string; cwd: string; env: Record }, wantVersion: boolean, - probe: typeof probeAgentCliVersion = probeAgentCliVersion -): Promise<{ supported: boolean; version: string | null }> { - const { supportsVersion } = spec - if (!supportsVersion && !wantVersion) { - return { supported: true, version: null } + read?: AgentCliVersionReader +): Promise { + if (!spec.minimumVersions && !wantVersion) { + return { status: 'unknown', version: null } } - const seen: { version: string | null } = { version: null } - const answered = await probe( + return checkAgentCliVersion( { program: launch.command, cwd: launch.cwd, env: launch.env }, - (version) => { - seen.version = version - return supportsVersion?.(version) ?? true - } + spec.minimumVersions ?? [], + read ) - // Without named releases a failed probe costs only what needed the version. - return { supported: answered || !supportsVersion, version: answered ? seen.version : null } -} - -/** Whether `command` is a release the spec runs a structured chat on; true when it names none. */ -export async function acpLaunchVersionSupported( - spec: AcpLaunchSpec, - launch: { command: string; cwd: string; env: Record }, - probe: typeof probeAgentCliVersion = probeAgentCliVersion -): Promise { - return (await probeAcpLaunchVersion(spec, launch, false, probe)).supported } /** The binary and environment any child of this agent runs under for `accountHome`: a session's @@ -234,16 +223,14 @@ export function acpStructuredLaunchPart( const cwd = await basis.launchDirectory() const visuals = spec.visualsSkill ? await basis.visuals() : null // Again at every launch: the binary on PATH may have changed since the chat was created. - const launchVersion = await probeAcpLaunchVersion( + const launchVersion = await checkAcpLaunchVersion( spec, { command, cwd, env }, visuals !== null, deps.probeVersion ) - if (!launchVersion.supported) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) + if (launchVersion.status === 'tooOld') { + throw AgentCliInstallationError.tooOld(spec.agent, launchVersion) } const skill = visuals ? await loadAcpVisualsSkill(spec, deps, { diff --git a/src/main/acp/acp-structured-launch-visuals.test.ts b/src/main/acp/acp-structured-launch-visuals.test.ts index a76fab886f8..8f252354628 100644 --- a/src/main/acp/acp-structured-launch-visuals.test.ts +++ b/src/main/acp/acp-structured-launch-visuals.test.ts @@ -44,7 +44,7 @@ function resolve( const spec = { ...acpLaunchSpecFor(agent)!, ...options.spec } const probeVersion = vi.fn< NonNullable[1]['probeVersion']> - >(async (_input, supports) => supports(options.version ?? '1.0.46')) + >(async () => ({ version: options.version ?? '1.0.46' })) const prepareVisuals = vi.fn(async () => options.visuals === false ? null : { folder: FOLDER, skill: SKILL } ) diff --git a/src/main/acp/acp-visuals-skill.ts b/src/main/acp/acp-visuals-skill.ts index da08a733923..d9970278e0f 100644 --- a/src/main/acp/acp-visuals-skill.ts +++ b/src/main/acp/acp-visuals-skill.ts @@ -1,7 +1,7 @@ // How each ACP agent loads the chat-visuals skill for one launch, without touching the user's own // config: ACP itself has no way to hand an agent a skill. -import { isStableCliVersionFrom } from '../agent-cli-version-probe' +import { hasReachedAppVersion } from '../../shared/app-version' import type { NativeChatVisualsSkillLocation } from '../native-chat/native-chat-visuals-skill-location' export type AcpVisualsSkillInput = { @@ -29,7 +29,7 @@ const GROK_PLUGIN_DIR_FIRST_VERSION = '1.0.44' /** Grok takes Claude's plugin layout through `agent --plugin-dir`, for this process only. */ export const loadGrokVisualsSkill: AcpVisualsSkillLoader = async ({ skill, version }) => - version !== null && isStableCliVersionFrom(version, GROK_PLUGIN_DIR_FIRST_VERSION) + version !== null && hasReachedAppVersion(version, GROK_PLUGIN_DIR_FIRST_VERSION) ? { pluginDir: skill.pluginDir } : null @@ -74,7 +74,7 @@ const OPENCODE_MERGED_SKILL_PATHS_FIRST_VERSION = '2.0.14' export const loadOpenCodeVisualsSkill: AcpVisualsSkillLoader = async ({ skill, env, version }) => { if ( version === null || - !isStableCliVersionFrom(version, OPENCODE_MERGED_SKILL_PATHS_FIRST_VERSION) + !hasReachedAppVersion(version, OPENCODE_MERGED_SKILL_PATHS_FIRST_VERSION) ) { return null } diff --git a/src/main/agent-cli-version-probe.test.ts b/src/main/agent-cli-version-probe.test.ts index eaac59509bf..a1e066dbace 100644 --- a/src/main/agent-cli-version-probe.test.ts +++ b/src/main/agent-cli-version-probe.test.ts @@ -1,16 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - isStableCliVersionFrom, - isStableCliVersionOnLine, - observeAgentCliVersion, - probeAgentCliVersion -} from './agent-cli-version-probe' +import { checkAgentCliVersion } from './agent-cli-version-probe' const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) vi.mock('@orca/process-host', () => ({ runProcess })) const INPUT = { program: '/opt/bin/agent', cwd: '/repo', env: { PATH: '/opt/bin' } } -const anyVersion = (): boolean => true +const OPENCODE_MINIMUMS = ['1.18.31', '2.0.14'] function prints(stdout: string, result: Record = {}): void { runProcess.mockResolvedValue({ @@ -23,14 +18,15 @@ function prints(stdout: string, result: Record = {}): void { }) } -describe('probeAgentCliVersion', () => { +describe('checkAgentCliVersion', () => { beforeEach(() => runProcess.mockReset()) it('asks the program for its version, bounded, where and as the launch would run it', async () => { prints('1.18.31\n') - const seen: string[] = [] - expect(await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true))).toBe(true) - expect(seen).toEqual(['1.18.31']) + expect(await checkAgentCliVersion(INPUT, OPENCODE_MINIMUMS)).toEqual({ + status: 'ready', + version: '1.18.31' + }) expect(runProcess).toHaveBeenCalledWith({ ...INPUT, args: ['--version'], @@ -40,84 +36,72 @@ describe('probeAgentCliVersion', () => { }) }) - it('reads a version printed with a name or a v prefix', async () => { - prints('opencode v2.0.21\n') - const seen: string[] = [] - await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) - expect(seen).toEqual(['2.0.21']) + it.each([ + ['opencode v2.0.21\n', '2.0.21'], + ['omp/17.0.5\n', '17.0.5'] + ])('reads a version printed with a name, prefix or slash: %j', async (stdout, version) => { + prints(stdout) + expect(await checkAgentCliVersion(INPUT, [])).toEqual({ status: 'ready', version }) }) - it('reads a version printed after the program name and a slash', async () => { - prints('omp/17.0.5\n') - const seen: string[] = [] - await probeAgentCliVersion(INPUT, (version) => (seen.push(version), true)) - expect(seen).toEqual(['17.0.5']) + it.each([ + ['1.18.31', null], + ['1.18.30', '1.18.31'], + ['2.0.13', '2.0.14'], + ['2.0.14-rc.1', '2.0.14'], + ['2.0.14', null], + ['3.0.0', null], + ['3.0.0-beta.1', null], + ['0.9.0', '1.18.31'] + ])('holds OpenCode %s to its minimum (too old for %s)', async (version, minimumVersion) => { + const check = await checkAgentCliVersion(INPUT, OPENCODE_MINIMUMS, async () => ({ version })) + expect(check).toEqual( + minimumVersion ? { status: 'tooOld', version, minimumVersion } : { status: 'ready', version } + ) + }) + + it('reads a version printed only on stderr, as Pi 0.73 does', async () => { + prints('', { stderr: '0.73.1\n' }) + expect(await checkAgentCliVersion(INPUT, ['0.84.0'])).toEqual({ + status: 'tooOld', + version: '0.73.1', + minimumVersion: '0.84.0' + }) }) it.each([ ['a non-zero exit', { code: 1 }], ['a timeout', { timedOut: true }], - ['oversized output', { outputTruncated: true }] + ['oversized output', { outputTruncated: true }], + ['no version in its output', { stdout: 'usage: agent [options]\n' }] ])('cannot tell after %s', async (_label, result) => { prints('1.18.31\n', result) - expect(await observeAgentCliVersion(INPUT, anyVersion)).toBe('inconclusive') - expect(await probeAgentCliVersion(INPUT, anyVersion)).toBe(false) + expect(await checkAgentCliVersion(INPUT, OPENCODE_MINIMUMS)).toEqual({ + status: 'unknown', + version: null + }) }) - it('cannot tell when the output names no version, or the program does not start', async () => { - prints('usage: agent [options]\n') - expect(await observeAgentCliVersion(INPUT, anyVersion)).toBe('inconclusive') + it('calls a program that does not start missing', async () => { runProcess.mockImplementationOnce(() => { throw new Error('spawn ENOENT') }) - expect(await observeAgentCliVersion(INPUT, anyVersion)).toBe('inconclusive') - // Only a release it read and refused is a verdict. - prints('1.18.31\n') - expect(await observeAgentCliVersion(INPUT, () => false)).toBe('unsupported') + expect(await checkAgentCliVersion(INPUT, OPENCODE_MINIMUMS)).toEqual({ + status: 'missing', + version: null + }) }) -}) - -describe('probeAgentCliVersion refusal', () => { - beforeEach(() => runProcess.mockReset()) it('says why it refused, with what the binary printed', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) prints('', { code: 1, stderr: 'Error: EACCES mkdir /x\n' }) - await probeAgentCliVersion(INPUT, anyVersion) - prints('2.0.21\n') - await probeAgentCliVersion(INPUT, () => false) + await checkAgentCliVersion(INPUT, OPENCODE_MINIMUMS) + prints('2.0.13\n') + await checkAgentCliVersion(INPUT, OPENCODE_MINIMUMS) expect(warn.mock.calls.map(([line]) => line)).toEqual([ '[agent-cli-version] /opt/bin/agent --version: exited 1: Error: EACCES mkdir /x', - '[agent-cli-version] /opt/bin/agent --version: 2.0.21 is not a supported release' + '[agent-cli-version] /opt/bin/agent --version: 2.0.13 is older than 2.0.14' ]) warn.mockRestore() }) }) - -describe('isStableCliVersionOnLine', () => { - const line = { major: 1, floor: '1.18.31' } - - it.each([ - ['1.18.31', true], - ['1.19.2', true], - ['1.18.30', false], - ['2.0.21', false], - ['1.18.31-beta.1', false], - ['11.0.0', false] - ])('%s -> %s', (version, supported) => { - expect(isStableCliVersionOnLine(version, line)).toBe(supported) - }) -}) - -describe('isStableCliVersionFrom', () => { - it.each([ - ['17.0.5', true], - ['17.2.12', true], - ['18.4.5', true], - ['17.0.4', false], - ['16.9.0', false], - ['18.0.0-beta.1', false] - ])('%s -> %s', (version, supported) => { - expect(isStableCliVersionFrom(version, '17.0.5')).toBe(supported) - }) -}) diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts index 6e176fc72a2..1ec6d948b3c 100644 --- a/src/main/agent-cli-version-probe.ts +++ b/src/main/agent-cli-version-probe.ts @@ -1,8 +1,4 @@ -import { - hasReachedAppVersion, - isPrereleaseAppVersion, - parseCliVersion -} from '../shared/app-version' +import { compareAppVersions, parseCliVersion } from '../shared/app-version' import { runProcess } from '@orca/process-host' import type { ProcessSpec } from '@orca/process-host/process-spec' @@ -12,39 +8,62 @@ function warnRefused(program: string, why: string, output = ''): void { console.warn(`[agent-cli-version] ${program} --version: ${why}${said ? `: ${said}` : ''}`) } -/** What one `--version` run showed; `inconclusive` is no verdict on the installed release. */ -export type AgentCliVersionObservation = 'supported' | 'unsupported' | 'inconclusive' +/** What one `--version` run says of the installed release. `unknown` (a slow, failed or wordless + * run) is no verdict, so a chat runs and any real start failure reports itself; `missing` is a + * program that did not start at all. */ +export type AgentCliVersionCheck = + | { status: 'ready'; version: string } + | { status: 'tooOld'; version: string; minimumVersion: string } + | { status: 'unknown' | 'missing'; version: null } -/** Runs ` --version` on this host and asks `supports` about the version it prints. - * Inconclusive when it cannot tell: a failed spawn, a non-zero exit, a timeout, or no version. */ -export async function observeAgentCliVersion( +/** Reads a launch binary's `--version`; injectable so tests name the release. */ +export type AgentCliVersionReader = ( + input: Pick +) => Promise<{ version: string | null; started?: false }> + +/** Runs ` --version` on this host and holds what it prints to `minimumVersions`. */ +export async function checkAgentCliVersion( input: Pick, - supports: (version: string) => boolean -): Promise { - const result = await readAgentCliVersion(input) - if (result.version === null) { - return 'inconclusive' + minimumVersions: readonly string[], + read: AgentCliVersionReader = readAgentCliVersion +): Promise { + const { version, started } = await read(input) + if (version === null) { + return { status: started === false ? 'missing' : 'unknown', version: null } } - if (!supports(result.version)) { - warnRefused(input.program, `${result.version} is not a supported release`) - return 'unsupported' + const minimumVersion = agentCliMinimumVersion(version, minimumVersions) + if (minimumVersion !== null && compareAppVersions(version, minimumVersion) < 0) { + warnRefused(input.program, `${version} is older than ${minimumVersion}`) + return { status: 'tooOld', version, minimumVersion } } - console.info(`[agent-cli-version] ${input.program} --version: ${result.version} is supported`) - return 'supported' + console.info(`[agent-cli-version] ${input.program} --version: ${version} is supported`) + return { status: 'ready', version } } -/** A launch's projection: only an observed supported release runs; an inconclusive run refuses. */ -export async function probeAgentCliVersion( - input: Pick, - supports: (version: string) => boolean -): Promise { - return (await observeAgentCliVersion(input, supports)) === 'supported' +/** Create support's answer: only a too-old binary or one that does not start keeps the terminal. */ +export function launchableRelease(check: AgentCliVersionCheck): boolean { + return check.status === 'ready' || check.status === 'unknown' +} + +/** The release `version` must reach: the latest of the ascending `minimumVersions` on or below its + * major line, so a later major needs no entry (and one major holds one floor); the first below + * them all; none when none is listed. */ +export function agentCliMinimumVersion( + version: string, + minimumVersions: readonly string[] +): string | null { + const major = (value: string): number => Number(value.split('.')[0]) + return ( + minimumVersions.findLast((minimum) => major(minimum) <= major(version)) ?? + minimumVersions[0] ?? + null + ) } export async function readAgentCliVersion( input: Pick, parseVersion: typeof parseCliVersion = parseCliVersion -): Promise<{ version: string | null }> { +): Promise<{ version: string | null; started?: false }> { let result try { result = await runProcess({ @@ -56,7 +75,7 @@ export async function readAgentCliVersion( }) } catch (error) { warnRefused(input.program, `did not start (${String(error)})`) - return { version: null } + return { version: null, started: false } } const output = `${result.stdout}\n${result.stderr}` if (result.timedOut || result.outputTruncated || result.code !== 0) { @@ -68,23 +87,10 @@ export async function readAgentCliVersion( warnRefused(input.program, why, output) return { version: null } } - const version = parseVersion(parseVersion === parseCliVersion ? result.stdout : output) + const version = parseVersion(output) if (version === null) { warnRefused(input.program, 'printed no version', output) return { version: null } } return { version } } - -/** A stable release at or after `floor`, on any later major line too. */ -export function isStableCliVersionFrom(version: string, floor: string): boolean { - return !isPrereleaseAppVersion(version) && hasReachedAppVersion(version, floor) -} - -/** A stable release on `major`'s line, at or after `floor`. */ -export function isStableCliVersionOnLine( - version: string, - line: { major: number; floor: string } -): boolean { - return version.startsWith(`${line.major}.`) && isStableCliVersionFrom(version, line.floor) -} diff --git a/src/main/codex/codex-cli-installation-error.ts b/src/main/codex/codex-cli-installation-error.ts index 8bedd601047..a4a56d55d7c 100644 --- a/src/main/codex/codex-cli-installation-error.ts +++ b/src/main/codex/codex-cli-installation-error.ts @@ -1,50 +1,32 @@ -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 { + CODEX_MINIMUM_SUPPORTED_VERSION, + parseCodexCliVersion +} from '../../shared/codex-cli-installation' +import { + checkAgentCliVersion, + readAgentCliVersion, + type AgentCliVersionReader +} from '../agent-cli-version-probe' +import { AgentCliInstallationError } from '../native-chat/agent-cli-installation-error' import type { StructuredAgentLaunchResolver } from '../runtime/structured-agent-launch-composition' import { resolveProviderChildEnv, type ProviderProcessLaunch } from '../provider-process/provider-process-launch' -import { findInErrorChain } from '../provider-process/provider-error-chain' -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): CodexInstallationProblem | undefined { - return findInErrorChain(error, (inner) => - inner instanceof CodexCliInstallationError ? inner.codexInstallation : undefined - ) -} +/** Codex's `--version`, which names the binary on its own line. */ +export const readCodexCliVersion: AgentCliVersionReader = (input) => + readAgentCliVersion(input, parseCodexCliVersion) export async function requireSupportedCodexCli( - input: Parameters[0], - readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation + input: Parameters[0], + readVersion: AgentCliVersionReader = readCodexCliVersion ): Promise { - const result = await readInstallation(input) - if (result.status === 'unsupported') { - throw new CodexCliInstallationError({ - installedVersion: result.version, - minimumVersion: result.minimumVersion - }) + const check = await checkAgentCliVersion(input, [CODEX_MINIMUM_SUPPORTED_VERSION], readVersion) + if (check.status === 'tooOld') { + throw AgentCliInstallationError.tooOld('codex', check) } - if (result.status === 'unknown') { + if (check.status !== 'ready') { console.warn(`[codex-cli-version] Could not verify ${input.program}; allowing structured chat`) } } @@ -52,7 +34,7 @@ export async function requireSupportedCodexCli( /** Check every acquired child against the same sealed launch the app-server will use. */ export function withSupportedCodexCli( resolveLaunch: StructuredAgentLaunchResolver, - readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation + readVersion: AgentCliVersionReader = readCodexCliVersion ): StructuredAgentLaunchResolver { return async (request) => { const launch = await resolveLaunch(request) @@ -62,7 +44,7 @@ export function withSupportedCodexCli( cwd: launch.cwd, env: resolveProviderChildEnv(launch, process.env) }, - readInstallation + readVersion ) return launch } diff --git a/src/main/codex/codex-minimum-version.test.ts b/src/main/codex/codex-minimum-version.test.ts index 1ee7c423bed..ff819f6d0d7 100644 --- a/src/main/codex/codex-minimum-version.test.ts +++ b/src/main/codex/codex-minimum-version.test.ts @@ -1,22 +1,18 @@ 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 type { AgentCliVersionReader } from '../agent-cli-version-probe' +import { AgentCliInstallationError } from '../native-chat/agent-cli-installation-error' 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 { requireSupportedCodexCli, withSupportedCodexCli } from './codex-cli-installation-error' import { createCodexStructuredLaunchResolver, TEST_SPAWN_TOKEN @@ -25,10 +21,10 @@ import { resolveProviderChildEnv } from '../provider-process/provider-process-la import { identityFor } from './codex-structured-session-adapter-fixture' import { openCodexAppServerConnection } from './codex-app-server-connection' -vi.mock('../preflight/codex-cli-installation', () => ({ readCodexCliInstallation: vi.fn() })) +const readVersion = vi.fn() beforeEach(() => { - vi.mocked(readCodexCliInstallation).mockReset() - vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0')) + readVersion.mockReset() + readVersion.mockResolvedValue({ version: '0.136.0' }) }) afterEach(() => vi.restoreAllMocks()) @@ -45,11 +41,12 @@ 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) + readVersion.mockResolvedValue({ version }) + const caught = await requireSupportedCodexCli( + { program: '/execution-host/codex' }, + readVersion + ).catch((error: unknown) => error) + expect(caught).toBeInstanceOf(AgentCliInstallationError) const wording = { record: record(), newSession: true } const result = failedAcquisitionRefusal(caught, wording) expect(result?.refusal.message).toBe( @@ -71,14 +68,16 @@ describe('Codex structured launch version admission', () => { ) 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() + readVersion.mockResolvedValue({ version: null, started: false }) + await expect( + requireSupportedCodexCli({ program: 'codex' }, readVersion) + ).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' }) + readVersion.mockResolvedValue({ version: null, started: false }) + await requireSupportedCodexCli({ program: 'codex.exe' }, readVersion) const child = createFakePipedChild() Object.assign(child, { pid: undefined }) const opening = openCodexAppServerConnection( @@ -102,9 +101,11 @@ describe('Codex structured launch version admission', () => { }) it('allows an unknown version with a log line', async () => { - vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, null)) + readVersion.mockResolvedValue({ version: null }) const log = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(requireSupportedCodexCli({ program: '/host/codex' })).resolves.toBeUndefined() + await expect( + requireSupportedCodexCli({ program: '/host/codex' }, readVersion) + ).resolves.toBeUndefined() expect(log).toHaveBeenCalledWith(expect.stringContaining('allowing structured chat')) log.mockRestore() }) @@ -131,25 +132,26 @@ describe('Codex structured launch version admission', () => { resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }), resolveLaunchArgs: () => [], resolveWorkspacePath: async () => '/folder' - }) + }), + readVersion ) - vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.135.0')) + readVersion.mockResolvedValue({ version: '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] + const probe = readVersion.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')) + readVersion.mockResolvedValue({ version: '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({ + expect(readVersion.mock.calls.at(-1)?.[0]).toEqual({ program: launch.command, cwd: launch.cwd, env: resolveProviderChildEnv(launch, process.env) @@ -172,9 +174,9 @@ describe('Codex structured launch version admission', () => { } ] } - vi.mocked(readCodexCliInstallation).mockImplementation(async (input) => - codexCliInstallation(true, input.cwd === '/workspace' ? '0.136.0' : '0.135.0') - ) + readVersion.mockImplementation(async (input) => ({ + version: input.cwd === '/workspace' ? '0.136.0' : '0.135.0' + })) const resolve = withSupportedCodexCli( createCodexStructuredLaunchResolver({ store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, @@ -182,7 +184,8 @@ describe('Codex structured launch version admission', () => { resolveEnvironment: async () => ({ PATH: '/host/bin' }), resolveLaunchArgs: () => [], resolveWorkspacePath: async () => '/workspace' - }) + }), + readVersion ) expect( (await resolve({ identity: identityFor(value.sessionId), spawnToken: TEST_SPAWN_TOKEN })) @@ -194,7 +197,6 @@ describe('Codex structured launch version admission', () => { 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', @@ -207,7 +209,6 @@ describe('Codex structured launch version admission', () => { ok: true, command: shell === 'cmd' ? 'codex "--model" "chosen"' : "codex '--model' 'chosen'" }) - expect(readCodexCliInstallation).not.toHaveBeenCalled() } ) }) diff --git a/src/main/native-chat/agent-cli-installation-error.ts b/src/main/native-chat/agent-cli-installation-error.ts new file mode 100644 index 00000000000..07928dc502e --- /dev/null +++ b/src/main/native-chat/agent-cli-installation-error.ts @@ -0,0 +1,37 @@ +import type { AgentCliInstallationProblem } from '../../shared/agent-cli-installation-problem' +import { + agentCliTooOldWords, + sayAgentSessionFailureEnglish +} from '../../shared/agent-session-failure-copy' +import type { TuiAgent } from '../../shared/tui-agent' +import { AgentSessionPreSpawnError } from './agent-session-wire/structured-agent-session-adapter' +import { findInErrorChain } from '../provider-process/provider-error-chain' + +/** A chat launch refused before spawning: the agent's binary is older than its chats need. */ +export class AgentCliInstallationError extends AgentSessionPreSpawnError { + readonly installation: AgentCliInstallationProblem + + constructor(installation: AgentCliInstallationProblem) { + super(agentCliTooOldWords(installation, sayAgentSessionFailureEnglish)) + this.installation = installation + } + + static tooOld( + agent: TuiAgent, + check: { version: string; minimumVersion: string } + ): AgentCliInstallationError { + return new AgentCliInstallationError({ + agent, + installedVersion: check.version, + minimumVersion: check.minimumVersion + }) + } +} + +export function agentCliInstallationProblemOf( + error: unknown +): AgentCliInstallationProblem | undefined { + return findInErrorChain(error, (inner) => + inner instanceof AgentCliInstallationError ? inner.installation : undefined + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts index b01020f7203..ebfb2beea3e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it } from 'vitest' -import { CodexCliInstallationError } from '../../codex/codex-cli-installation-error' +import { AgentCliInstallationError } from '../agent-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' @@ -41,7 +41,8 @@ async function retainedDraft() { await rig.settleAccepted(working, 'earlier-turn') await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() rig.starts.mockImplementationOnce(() => { - throw new CodexCliInstallationError({ + throw new AgentCliInstallationError({ + agent: 'codex', installedVersion: '0.135.0', minimumVersion: '0.136.0' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index d88997c46c6..955524fee91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -30,7 +30,8 @@ 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 { agentCliInstallationProblemOf } from '../agent-cli-installation-error' +import { agentCliInstallationFacts } from '../../../shared/agent-cli-installation-problem' import { providerDiagnosticOf } from '../../../shared/agent-session-failure' /** Who a failed acquisition's sentence names, and whether it was the session's first start. */ @@ -114,9 +115,9 @@ 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 installation = agentCliInstallationProblemOf(error) + if (installation) { + return { reason: 'attachFailed', ...agentCliInstallationFacts(installation) } } const argumentProblem = argumentProblemOf(error) if (argumentProblem) { @@ -148,7 +149,7 @@ export function failedAcquisitionRefusal( error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error) || (isAgentSessionPreSpawnError(error) && - (argumentProblemOf(error) || codexInstallationProblemOf(error))) + (argumentProblemOf(error) || agentCliInstallationProblemOf(error))) ) { return { ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index b3ff76bcf90..83de3012cc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -24,7 +24,8 @@ import { providerExecutableMissing } from '../../provider-process/provider-execu import { providerRequestTimeoutOf } from '../../provider-process/provider-request-timeout' import { findInErrorChain } from '../../provider-process/provider-error-chain' import { argumentProblemOf } from '../structured-agent-arguments-error' -import { codexInstallationProblemOf } from '../../codex/codex-cli-installation-error' +import { agentCliInstallationProblemOf } from '../agent-cli-installation-error' +import { agentCliInstallationFacts } from '../../../shared/agent-cli-installation-problem' /** Start refusals whose situation is itself what the person reads, with its own next step. */ const TYPED_START_REFUSALS = [ @@ -67,12 +68,12 @@ export function providerExitObserved(error: unknown): 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) { + const installation = agentCliInstallationProblemOf(cause) + if (installation) { return agentSessionFailureFact('startFailed', { refusal: { code: 'agent_session_operation_invalid', - details: { reason: 'attachFailed', codexInstallation } + details: { reason: 'attachFailed', ...agentCliInstallationFacts(installation) } } }) } diff --git a/src/main/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts index fa1309ccd04..531ed2023af 100644 --- a/src/main/pi/rpc-launch-resolution.test.ts +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -12,7 +12,9 @@ import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record import { buildPiRpcLaunch } from './rpc-launch' import { piRpcProviderLink } from './rpc-launch-resolution' -const probeVersion = vi.fn(async (..._input: unknown[]) => true) +const probeVersion = vi.fn(async (..._input: unknown[]): Promise<{ version: string | null }> => ({ + version: '0.84.0' +})) const identity: AgentSessionJournalIdentity = { sessionId: 'session-pi-resolve', @@ -23,7 +25,7 @@ const identity: AgentSessionJournalIdentity = { } let root: string beforeEach(async () => { - probeVersion.mockReset().mockResolvedValue(true) + probeVersion.mockReset().mockResolvedValue({ version: '0.84.0' }) root = await mkdtemp(join(tmpdir(), 'orca-pi-resolver-')) }) afterEach(async () => { @@ -89,18 +91,17 @@ async function setup() { } describe('Pi host launch resolution', () => { - it('refuses acquisition if the selected binary was replaced by an unsupported version', async () => { + it('refuses acquisition if the selected binary was replaced by a too-old version', async () => { const h = await setup() - probeVersion.mockResolvedValue(false) - await expect(h.resolver(identity)).rejects.toThrow('structured_agent_session_unsupported') - expect(probeVersion).toHaveBeenCalledWith( - { - program: '/host/bin/pi', - cwd: h.workspace, - env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' } - }, - expect.any(Function) - ) + probeVersion.mockResolvedValue({ version: '0.83.9' }) + await expect(h.resolver(identity)).rejects.toMatchObject({ + installation: { agent: 'pi', installedVersion: '0.83.9', minimumVersion: '0.84.0' } + }) + expect(probeVersion).toHaveBeenCalledWith({ + program: '/host/bin/pi', + cwd: h.workspace, + env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' } + }) }) it('uses the runtime workspace, binary and account home for a new folder session', async () => { diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts index 78ca0b3d79a..f4c5ec61c00 100644 --- a/src/main/pi/rpc-launch-resolution.ts +++ b/src/main/pi/rpc-launch-resolution.ts @@ -9,7 +9,12 @@ import { } from '../../shared/agent-session-provider-handle' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' -import { isStableCliVersionOnLine, probeAgentCliVersion } from '../agent-cli-version-probe' +import { + checkAgentCliVersion, + type AgentCliVersionCheck, + type AgentCliVersionReader +} from '../agent-cli-version-probe' +import { AgentCliInstallationError } from '../native-chat/agent-cli-installation-error' import { resolveAgainAfterRefreshOnMissingProgram, resolveStructuredAgentCommand, @@ -30,20 +35,13 @@ export type PiRpcResolvedLaunch = PiRpcLaunchOptions & { } export type PiRpcLaunchPartDeps = { resolveCommand?: typeof resolveCliCommand - probeVersion?: typeof probeAgentCliVersion + probeVersion?: AgentCliVersionReader resolveFullAccess?: () => boolean } -// Stable releases from 0.84.0 emit agent_settled after retries and detached compaction; older -// Pi (the 0.73.x package included) keeps the terminal chat. -const PI_RPC_RELEASE_LINES = [ - { major: 0, floor: '0.84.0' }, - { major: 1, floor: '1.0.0' } -] as const - -export function supportsPiRpcVersion(version: string): boolean { - return PI_RPC_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)) -} +// From 0.84.0 Pi emits agent_settled after retries and detached compaction, which a chat's turn +// waits on; older Pi (the 0.73.x package included) runs only in a terminal. +export const PI_RPC_MINIMUM_VERSIONS = ['0.84.0'] as const /** The binary a Pi launch with `env` spawns; create support resolves through here too. */ export function resolvePiRpcCommand( @@ -60,11 +58,15 @@ export function resolvePiRpcCommand( ) } -export function piRpcVersionSupported( +export function checkPiRpcVersion( launch: { command: string; cwd: string; env: Record }, - probe: typeof probeAgentCliVersion = probeAgentCliVersion -): Promise { - return probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, supportsPiRpcVersion) + read?: AgentCliVersionReader +): Promise { + return checkAgentCliVersion( + { program: launch.command, cwd: launch.cwd, env: launch.env }, + PI_RPC_MINIMUM_VERSIONS, + read + ) } const headerSchema = z.object({ @@ -186,10 +188,9 @@ export function piRpcLaunchPart( } } // Again at every launch: the binary on PATH may have changed since the chat was created. - if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostUnsupported' - }) + const launchVersion = await checkPiRpcVersion({ command, cwd, env }, deps.probeVersion) + if (launchVersion.status === 'tooOld') { + throw AgentCliInstallationError.tooOld('pi', launchVersion) } return { launch: { diff --git a/src/main/pi/rpc-model-catalog-probe.test.ts b/src/main/pi/rpc-model-catalog-probe.test.ts index d775c3520c2..99d198cdd33 100644 --- a/src/main/pi/rpc-model-catalog-probe.test.ts +++ b/src/main/pi/rpc-model-catalog-probe.test.ts @@ -32,7 +32,7 @@ describe('Pi model catalog', () => { ORCA_PANE_KEY: 'p' }), resolveCommand: () => '/opt/pi/bin/pi', - probeVersion: async () => true, + probeVersion: async () => ({ version: '0.84.0' }), homePath: '/home/user', runListing }) @@ -53,7 +53,7 @@ describe('Pi model catalog', () => { const probe = createPiModelCatalogProbe({ resolveEnvironment: async () => ({ PATH: '/usr/bin' }), resolveCommand: () => '/opt/pi/bin/pi', - probeVersion: async () => false, + probeVersion: async () => ({ version: '0.83.9' }), runListing }) await expect(probe({ variable: 'PI_CODING_AGENT_DIR', path: '/homes/pi-a' })).rejects.toThrow( diff --git a/src/main/pi/rpc-model-catalog-probe.ts b/src/main/pi/rpc-model-catalog-probe.ts index 7dbdf8462d6..e384d1c123b 100644 --- a/src/main/pi/rpc-model-catalog-probe.ts +++ b/src/main/pi/rpc-model-catalog-probe.ts @@ -2,7 +2,7 @@ import { homedir } from 'node:os' import { requireLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' import { parsePiModels } from '../../shared/commit-message-model-parsers' import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' -import type { probeAgentCliVersion } from '../agent-cli-version-probe' +import type { AgentCliVersionReader } from '../agent-cli-version-probe' import type { AgentModelCatalogProbe, AgentModelCatalogSuccess @@ -10,7 +10,7 @@ import type { import { runAgentModelCatalogListing } from '../native-chat/agent-model-catalog/agent-model-catalog-probe-runner' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import { buildPiRpcLaunch, piRpcSessionlessEnvironment } from './rpc-launch' -import { piRpcVersionSupported, resolvePiRpcInvocation } from './rpc-launch-resolution' +import { checkPiRpcVersion, resolvePiRpcInvocation } from './rpc-launch-resolution' import { PI_UNMAPPED_THINKING_LEVELS } from './rpc-options' export type PiModelCatalogProbeDeps = { @@ -20,7 +20,7 @@ export type PiModelCatalogProbeDeps = { resolveCommandSettings?: () => StructuredAgentCommandSettings resolveCommand?: typeof resolveCliCommand /** Test seams; production runs the shared supervised listing. */ - probeVersion?: typeof probeAgentCliVersion + probeVersion?: AgentCliVersionReader runListing?: typeof runAgentModelCatalogListing homePath?: string } @@ -50,7 +50,7 @@ export function createPiModelCatalogProbe(deps: PiModelCatalogProbeDeps): AgentM refreshEnvironmentAfterMiss: deps.refreshEnvironmentAfterMiss, resolveWorkingDirectory: () => cwd }) - if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { + if ((await checkPiRpcVersion({ command, cwd, env }, deps.probeVersion)).status === 'tooOld') { throw new Error(`pi at ${command} does not run structured chats`) } // The chat launch's own env rules; only the arguments differ. diff --git a/src/main/pi/rpc-runtime-metadata.ts b/src/main/pi/rpc-runtime-metadata.ts index a157752f6ac..3cabbfb0765 100644 --- a/src/main/pi/rpc-runtime-metadata.ts +++ b/src/main/pi/rpc-runtime-metadata.ts @@ -4,7 +4,7 @@ import { agentSessionAccountHome } from '../../shared/agent-session-account-home import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' import type { StructuredAgentRuntimeMetadata } from '../runtime/structured-agent-runtime-registration-types' import { PI_RPC_AGENT } from './rpc-agent-definition' -import { resolvePiRpcCommand, supportsPiRpcVersion } from './rpc-launch-resolution' +import { PI_RPC_MINIMUM_VERSIONS, resolvePiRpcCommand } from './rpc-launch-resolution' import { createPiModelCatalogProbe } from './rpc-model-catalog-probe' import { PI_SESSION_HISTORY } from './pi-session-history' import { PI_TRANSCRIPT_IMPORT } from './pi-transcript-import' @@ -32,7 +32,7 @@ export const PI_RPC_RUNTIME_METADATA: StructuredAgentRuntimeMetadata = { command: resolvePiRpcCommand(env, commandSettings), env }), - supportsVersion: supportsPiRpcVersion + minimumVersions: PI_RPC_MINIMUM_VERSIONS }, resolveAccountHome: async ({ launchEnv }) => agentSessionAccountHome( diff --git a/src/main/pi/rpc-runtime-registration.test.ts b/src/main/pi/rpc-runtime-registration.test.ts index d45c92e23ea..9ea88f60287 100644 --- a/src/main/pi/rpc-runtime-registration.test.ts +++ b/src/main/pi/rpc-runtime-registration.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runProcess } from '@orca/process-host' -import { observeAgentCliVersion } from '../agent-cli-version-probe' +import { checkAgentCliVersion, launchableRelease } from '../agent-cli-version-probe' import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentCommandSettings } from '../native-chat/structured-agent-command-resolution' import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration' @@ -22,11 +22,9 @@ async function supportsLaunch({ cwd, env, commandSettings }: LaunchInput): Promi throw new Error('Pi registers a launch check') } const { command, env: launchEnv } = check.resolveLaunch(env, commandSettings) - const seen = await observeAgentCliVersion( - { program: command, cwd, env: launchEnv }, - check.supportsVersion + return launchableRelease( + await checkAgentCliVersion({ program: command, cwd, env: launchEnv }, check.minimumVersions) ) - return seen === 'supported' } let root: string let stockPi: string @@ -63,8 +61,8 @@ const input = (commandSettings: LaunchInput['commandSettings'] = {}): LaunchInpu }) describe('Pi launch support before session creation', () => { - it.each(['0.84.0', '0.99.0', '1.0.0', '1.0.4\n', 'pi v1.12.3'])( - 'accepts stable Pi from 0.84.0: %s', + it.each(['0.84.0', '0.99.0', '1.0.0', '1.0.4\n', 'pi v1.12.3', '1.1.0-preview.1', '2.0.0'])( + 'accepts any Pi from 0.84.0, later majors and pre-releases included: %s', async (out) => { prints(out) await expect(supportsLaunch(input())).resolves.toBe(true) @@ -80,27 +78,22 @@ describe('Pi launch support before session creation', () => { } ) - it.each([ - '0.73.1', - '0.83.9', - '0.84.0-rc.1', - '1.0.0-rc.1', - '1.1.0-preview.1', - '2.0.0', - 'unknown', - '' - ])('keeps the terminal chat for an older, prerelease or unknown version: %s', async (out) => { - prints(out) - await expect(supportsLaunch(input())).resolves.toBe(false) - }) + it.each(['0.73.1', '0.83.9', '0.84.0-rc.1'])( + 'keeps the terminal chat for a release older than 0.84.0: %s', + async (out) => { + prints(out) + await expect(supportsLaunch(input())).resolves.toBe(false) + } + ) + // A version it cannot read is no verdict: the chat starts and any real failure reports itself. it.each([ { code: 1, timedOut: false, outputTruncated: false }, { code: 0, timedOut: true, outputTruncated: false }, { code: 0, timedOut: false, outputTruncated: true } - ])('does not infer support from incomplete or failed output: %j', async (result) => { + ])('runs the chat when the version cannot be read: %j', async (result) => { prints('1.0.4', result) - await expect(supportsLaunch(input())).resolves.toBe(false) + await expect(supportsLaunch(input())).resolves.toBe(true) }) it('probes the binary the Command setting names, not the stock one', async () => { diff --git a/src/main/preflight/codex-cli-installation.test.ts b/src/main/preflight/codex-cli-installation.test.ts index 6ffe6aadbad..5dd0d395570 100644 --- a/src/main/preflight/codex-cli-installation.test.ts +++ b/src/main/preflight/codex-cli-installation.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { readCodexCliInstallation } from './codex-cli-installation' +import { CODEX_MINIMUM_SUPPORTED_VERSION } from '../../shared/codex-cli-installation' +import { checkAgentCliVersion } from '../agent-cli-version-probe' +import { readCodexCliVersion } from '../codex/codex-cli-installation-error' const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) vi.mock('@orca/process-host', async (original) => ({ @@ -7,6 +9,9 @@ vi.mock('@orca/process-host', async (original) => ({ runProcess })) +const checkCodex = (input: Parameters[0]) => + checkAgentCliVersion(input, [CODEX_MINIMUM_SUPPORTED_VERSION], readCodexCliVersion) + function prints(stdout: string, timedOut = false): void { runProcess.mockResolvedValue({ code: 0, stdout, stderr: '', timedOut }) } @@ -22,7 +27,7 @@ describe('Codex installation version probes', () => { 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((await readCodexCliVersion(input)).version).toBe(version) } expect(runProcess).toHaveBeenCalledTimes(3) expect(runProcess).toHaveBeenCalledWith( @@ -39,9 +44,9 @@ describe('Codex installation version probes', () => { 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') + expect((await checkCodex({ program })).status).toBe('unknown') prints('codex-cli 0.136.0') - expect((await readCodexCliInstallation({ program })).status).toBe('ready') + expect((await checkCodex({ program })).status).toBe('ready') expect(runProcess).toHaveBeenCalledTimes(2) }) @@ -50,7 +55,7 @@ describe('Codex installation version probes', () => { async (code) => { const program = '/fake/codex' runProcess.mockRejectedValue(Object.assign(new Error('unavailable'), { code })) - expect((await readCodexCliInstallation({ program })).status).toBe('unknown') + expect((await checkCodex({ program })).status).toBe('missing') } ) }) diff --git a/src/main/preflight/codex-cli-installation.ts b/src/main/preflight/codex-cli-installation.ts deleted file mode 100644 index d33d192b1f1..00000000000 --- a/src/main/preflight/codex-cli-installation.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - codexCliInstallation, - parseCodexCliVersion, - type CodexCliInstallation -} from '../../shared/codex-cli-installation' -import type { ProcessSpec } from '@orca/process-host/process-spec' -import { readAgentCliVersion } from '../agent-cli-version-probe' - -export async function readCodexCliInstallation( - input: Pick -): Promise { - const result = await readAgentCliVersion(input, parseCodexCliVersion) - return codexCliInstallation(true, result.version) -} diff --git a/src/main/preflight/codex-workspace-relative-path.test.ts b/src/main/preflight/codex-workspace-relative-path.test.ts index 118a5d68314..4b9602f7fc0 100644 --- a/src/main/preflight/codex-workspace-relative-path.test.ts +++ b/src/main/preflight/codex-workspace-relative-path.test.ts @@ -10,12 +10,11 @@ import { createCodexStructuredLaunchResolver, TEST_SPAWN_TOKEN } from '../runtime/structured-agent-launch-composition.test-support' -import { withSupportedCodexCli } from '../codex/codex-cli-installation-error' +import { readCodexCliVersion, 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', () => ({ @@ -118,10 +117,10 @@ describe.skipIf(process.platform === 'win32')('workspace-relative Codex selectio mode: 0o755 }) const input = { program: join('bin', 'codex'), cwd: root, env: { PATH: '' } } - expect((await readCodexCliInstallation(input)).status).toBe('ready') + expect(await readCodexCliVersion(input)).toEqual({ version: '0.136.0' }) await writeFile(file, `#!${process.execPath}\nconsole.log('codex-cli 0.135.0')\n`, { mode: 0o755 }) - expect((await readCodexCliInstallation(input)).status).toBe('unsupported') + expect(await readCodexCliVersion(input)).toEqual({ version: '0.135.0' }) }) }) diff --git a/src/main/runtime/scripted-codex-transport.test-fixture.ts b/src/main/runtime/scripted-codex-transport.test-fixture.ts index 851e0d36f32..c954cd3673e 100644 --- a/src/main/runtime/scripted-codex-transport.test-fixture.ts +++ b/src/main/runtime/scripted-codex-transport.test-fixture.ts @@ -1,7 +1,4 @@ -import { - CODEX_MINIMUM_SUPPORTED_VERSION, - codexCliInstallation -} from '../../shared/codex-cli-installation' +import { CODEX_MINIMUM_SUPPORTED_VERSION } 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 @@ -14,7 +11,7 @@ export function scriptedCodexTransport( > { return { openCodexConnection: openConnection, - readCodexInstallation: async () => codexCliInstallation(true, CODEX_MINIMUM_SUPPORTED_VERSION), + readCodexInstallation: async () => ({ version: CODEX_MINIMUM_SUPPORTED_VERSION }), readProcessStartTime: async () => 1_700_000_000_000 } } diff --git a/src/main/runtime/structured-agent-availability.test.ts b/src/main/runtime/structured-agent-availability.test.ts index 6d030236576..e98acc51b54 100644 --- a/src/main/runtime/structured-agent-availability.test.ts +++ b/src/main/runtime/structured-agent-availability.test.ts @@ -27,9 +27,12 @@ vi.mock('./structured-agent-runtime-metadata', () => ({ import { installStructuredAgentAvailability } from './structured-agent-availability' +const READY = { status: 'ready', version: '2.0.14' } as const +const TOO_OLD = { status: 'tooOld', version: '2.0.13', minimumVersion: '2.0.14' } as const + beforeEach(() => { mocks.refreshed.clear() - mocks.observe.mockReset().mockResolvedValue('supported') + mocks.observe.mockReset().mockResolvedValue(READY) mocks.recapture.mockReset().mockResolvedValue(undefined) }) @@ -57,7 +60,7 @@ it('shares a host-home snapshot even if shell recapture fails, retaining it with if (agent === 'pi') { throw new Error('Command setting not runnable') } - return agent === 'opencode' ? 'unsupported' : 'supported' + return agent === 'opencode' ? TOO_OLD : READY }) const { availability } = install() const first = vi.fn() @@ -88,7 +91,7 @@ it('recaptures for settings and uses the completed capture on Refresh', async () change({ agentCmdOverrides: { pi: '/opt/pi' } }) await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) const capture = Promise.withResolvers() - let version = 'unsupported' + let version: typeof READY | typeof TOO_OLD = TOO_OLD mocks.observe.mockImplementation(async () => version) mocks.recapture.mockImplementation(() => capture.promise) mocks.refreshed.forEach((listener) => listener()) @@ -97,7 +100,7 @@ it('recaptures for settings and uses the completed capture on Refresh', async () await new Promise((resolve) => setImmediate(resolve)) expect(read).toHaveBeenCalledTimes(2) expect(mocks.observe).toHaveBeenCalledTimes(9) - version = 'supported' + version = READY capture.resolve() await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(3)) expect(read.mock.lastCall?.[0]).toContainEqual({ agent: 'opencode', state: 'available' }) @@ -105,7 +108,7 @@ it('recaptures for settings and uses the completed capture on Refresh', async () it('captures fresh shell facts for a first subscriber even when Refresh had no listener', async () => { const capture = Promise.withResolvers() - let version = 'unsupported' + let version: typeof READY | typeof TOO_OLD = TOO_OLD mocks.observe.mockImplementation(async () => version) mocks.recapture.mockImplementation(() => capture.promise) mocks.refreshed.forEach((listener) => listener()) @@ -114,7 +117,7 @@ it('captures fresh shell facts for a first subscriber even when Refresh had no l availability.subscribe(read) await new Promise((resolve) => setImmediate(resolve)) expect(mocks.observe).not.toHaveBeenCalled() - version = 'supported' + version = READY capture.resolve() await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(1)) expect(read.mock.lastCall?.[0]).toContainEqual({ agent: 'opencode', state: 'available' }) diff --git a/src/main/runtime/structured-agent-availability.ts b/src/main/runtime/structured-agent-availability.ts index 15811ffaace..bc42faa1d11 100644 --- a/src/main/runtime/structured-agent-availability.ts +++ b/src/main/runtime/structured-agent-availability.ts @@ -52,13 +52,13 @@ async function observeHostAgent( async () => homedir(), runtime, false - ).catch(() => 'inconclusive' as const) - if (seen === 'inconclusive') { - return { agent, state: 'unknown' } + ).catch(() => ({ status: 'unknown' as const })) + if (seen === null || seen.status === 'ready') { + return { agent, state: 'available' } } - return seen === 'supported' - ? { agent, state: 'available' } - : { agent, state: 'refused', reason: 'agent' } + return seen.status === 'tooOld' + ? { agent, state: 'refused', reason: 'agent' } + : { agent, state: 'unknown' } } /** The runtime's snapshot: taken after a fresh shell capture, initially and on launch settings or diff --git a/src/main/runtime/structured-agent-launch-support.test.ts b/src/main/runtime/structured-agent-launch-support.test.ts index 4d7ad5b2235..429ee66f334 100644 --- a/src/main/runtime/structured-agent-launch-support.test.ts +++ b/src/main/runtime/structured-agent-launch-support.test.ts @@ -8,12 +8,13 @@ import { acpLaunchSpecFor } from '../acp/acp-launch-specs' import { structuredAgentSupportsLaunch } from './structured-agent-launch-support' import { createStructuredAgentEnvironmentResolvers } from './structured-agent-shell-environment' import type * as LoginShellModule from '../startup/login-shell-environment' +import type * as VersionProbeModule from '../agent-cli-version-probe' import { resetLoginShellEnvironmentCacheForTests, type ResolveLoginShellEnvironmentOptions as LoginShellOptions } from '../startup/login-shell-environment' -const { observeAgentCliVersion, probeAgentCliVersion, loginShell } = vi.hoisted(() => { +const { readVersion, loginShell } = vi.hoisted(() => { const loginShell: { env: NodeJS.ProcessEnv spawn: ReturnType Promise>> @@ -22,13 +23,16 @@ const { observeAgentCliVersion, probeAgentCliVersion, loginShell } = vi.hoisted( // Each call is one shell run through the real cache; a test can hold one open. spawn: vi.fn(async (): Promise => loginShell.env) } - return { observeAgentCliVersion: vi.fn(), probeAgentCliVersion: vi.fn(), loginShell } + return { readVersion: vi.fn(), loginShell } +}) +vi.mock('../agent-cli-version-probe', async (importOriginal) => { + const real = await importOriginal() + return { + ...real, + checkAgentCliVersion: (...[input, minimums]: Parameters) => + real.checkAgentCliVersion(input, minimums, readVersion) + } }) -vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ - ...(await importOriginal()), - observeAgentCliVersion, - probeAgentCliVersion -})) vi.mock('../startup/login-shell-environment', async (importOriginal) => { const real = await importOriginal() return { @@ -43,16 +47,11 @@ vi.mock('../startup/login-shell-environment', async (importOriginal) => { }) let bin: string -const asked: { program: string; cwd: string; env: Record }[] = [] +const asked: Parameters[0][] = [] function installedVersion(version: string): void { - // The create check observes, the launch projects; both ask the same binary. - observeAgentCliVersion.mockImplementation(async (input, supports) => { + readVersion.mockImplementation(async (input) => { asked.push(input) - return supports(version) ? 'supported' : 'unsupported' - }) - probeAgentCliVersion.mockImplementation(async (input, supports) => { - asked.push(input) - return supports(version) + return { version } }) } @@ -75,8 +74,7 @@ beforeEach(async () => { resetLoginShellEnvironmentCacheForTests() }) afterEach(async () => { - observeAgentCliVersion.mockReset() - probeAgentCliVersion.mockReset() + readVersion.mockReset() workspace.mockClear() await rm(bin, { recursive: true, force: true }) }) @@ -84,19 +82,18 @@ afterEach(async () => { describe('structuredAgentSupportsLaunch', () => { it('asks nothing for an agent whose location alone decides', async () => { expect(await structuredAgentSupportsLaunch('grok', workspace, runtime)).toBe(true) - expect(observeAgentCliVersion).not.toHaveBeenCalled() + expect(readVersion).not.toHaveBeenCalled() expect(workspace).not.toHaveBeenCalled() }) - it('allows stable OpenCode 1.x and 2.x, asking in the workspace with the launch env', async () => { + it('allows OpenCode from its minimums, asking in the workspace with the launch env', async () => { for (const [version, supported] of [ ['1.18.31', true], ['2.0.14', true], - ['2.0.21', true], + ['2.1.0-beta.1', true], + ['3.0.0', true], ['1.18.30', false], - ['2.0.13', false], - ['2.1.0-beta.1', false], - ['3.0.0', false] + ['2.0.13', false] ] as const) { installedVersion(version) expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(supported) @@ -151,8 +148,10 @@ describe('structuredAgentSupportsLaunch', () => { expect(asked).toHaveLength(2) }) - it('refuses when the version cannot be read', async () => { - observeAgentCliVersion.mockResolvedValue('inconclusive') + it('allows a version it cannot read, and refuses a binary that does not start', async () => { + readVersion.mockResolvedValue({ version: null }) + expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(true) + readVersion.mockResolvedValue({ version: null, started: false }) expect(await structuredAgentSupportsLaunch('opencode', workspace, runtime)).toBe(false) }) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 7a84340252f..43f6f2da998 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -3,7 +3,11 @@ import type { StructuredAgentId } from '../../shared/agent-session-provider-hand import type { GlobalSettings } from '../../shared/global-settings-types' import { isTuiAgent } from '../../shared/tui-agent-config' import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' -import { observeAgentCliVersion, type AgentCliVersionObservation } from '../agent-cli-version-probe' +import { + checkAgentCliVersion, + launchableRelease, + type AgentCliVersionCheck +} from '../agent-cli-version-probe' import { resolveStructuredAgentSessionCreateSupport, warnStructuredAgentSessionCreateUnsupported, @@ -35,17 +39,17 @@ type LaunchSupportRuntime = LaunchSettingsRuntime & { } /** The agent's own check of the release installed on this host, with the environment and Command - * setting its launch starts from, run in `cwd`; supported for an agent whose location alone - * decides. Throws the launch's pre-spawn refusal for an unrunnable Command setting. */ + * setting its launch starts from, run in `cwd`; null for an agent whose location alone decides. + * Throws the launch's pre-spawn refusal for an unrunnable Command setting. */ export async function observeStructuredAgentLaunch( agent: string, cwd: () => Promise, runtime: LaunchSettingsRuntime, refreshShellOnMiss: boolean -): Promise { +): Promise { const check = structuredAgentRuntimeMetadata(agent)?.launchCheck if (!check) { - return 'supported' + return null } const settings = runtime.requireStore().getSettings() let directory: Promise | undefined @@ -61,20 +65,22 @@ export async function observeStructuredAgentLaunch( refreshShellOnMiss ? refreshHostAgentBaseEnvironmentAfterMiss : undefined, { env: ({ env }) => env, cwd: once } ) - return observeAgentCliVersion( + return checkAgentCliVersion( { program: launch.command, cwd: await once(), env: launch.env }, - check.supportsVersion + check.minimumVersions ) } -/** Admission's answer in the workspace `cwd` names: only an observed supported release launches. */ +/** Admission's answer in the workspace `cwd` names: only a too-old or unstartable binary keeps the + * terminal. */ export async function structuredAgentSupportsLaunch( agent: string, cwd: () => Promise, runtime: LaunchSettingsRuntime ): Promise { try { - return (await observeStructuredAgentLaunch(agent, cwd, runtime, true)) === 'supported' + const release = await observeStructuredAgentLaunch(agent, cwd, runtime, true) + return release === null || launchableRelease(release) } catch (error) { // An unrunnable Command setting is the launch's refusal to state, not a terminal. if (isAgentSessionPreSpawnError(error)) { diff --git a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts index 15630046322..20db5aec4d4 100644 --- a/src/main/runtime/structured-agent-model-catalog-discovery.test.ts +++ b/src/main/runtime/structured-agent-model-catalog-discovery.test.ts @@ -18,8 +18,9 @@ vi.mock('../provider-process/managed-provider-process', () => ({ throw new Error('no spawn in this test') } })) -vi.mock('../agent-cli-version-probe', () => ({ - probeAgentCliVersion: async (input: { program: string }) => { +vi.mock('../agent-cli-version-probe', async (importOriginal) => ({ + ...(await importOriginal()), + checkAgentCliVersion: async (input: { program: string }) => { spawned.commands.push(input.program) throw new Error('no spawn in this test') } diff --git a/src/main/runtime/structured-agent-runtime-metadata.ts b/src/main/runtime/structured-agent-runtime-metadata.ts index e5b668db97a..32b61e65d0d 100644 --- a/src/main/runtime/structured-agent-runtime-metadata.ts +++ b/src/main/runtime/structured-agent-runtime-metadata.ts @@ -31,11 +31,11 @@ export type StructuredAgentRuntimeCatalogEntry = | { kind: 'acp'; metadata: StructuredAgentRuntimeMetadata; spec: AcpLaunchSpec } function acpMetadata(spec: AcpLaunchSpec): StructuredAgentRuntimeMetadata { - const { supportsVersion } = spec + const { minimumVersions } = spec return { definition: acpStructuredAgentDefinition(spec), supportsLocation: (location) => supportsAcpStructuredLocation(spec, location), - ...(supportsVersion + ...(minimumVersions ? { launchCheck: { resolveLaunch: (shellEnv, commandSettings) => { @@ -46,7 +46,7 @@ function acpMetadata(spec: AcpLaunchSpec): StructuredAgentRuntimeMetadata { }) return { command, env } }, - supportsVersion + minimumVersions } } : {}), diff --git a/src/main/runtime/structured-agent-runtime-registration-types.ts b/src/main/runtime/structured-agent-runtime-registration-types.ts index a64a4a87dda..b0fb9f91c2e 100644 --- a/src/main/runtime/structured-agent-runtime-registration-types.ts +++ b/src/main/runtime/structured-agent-runtime-registration-types.ts @@ -73,8 +73,8 @@ export type StructuredAgentLaunchCheck = { env: Record, commandSettings: StructuredAgentCommandSettings ) => { command: string; env: Record } - /** Whether a release that binary prints on `--version` runs a structured chat. */ - supportsVersion: (version: string) => boolean + /** The ascending releases that binary's `--version` must reach; see `agentCliMinimumVersion`. */ + minimumVersions: readonly string[] } export type StructuredAgentRuntimeMetadata = StructuredAgentCliConversations & { diff --git a/src/main/runtime/structured-agent-session-transport-overrides.ts b/src/main/runtime/structured-agent-session-transport-overrides.ts index eed64bb8ba1..ea6db27d32d 100644 --- a/src/main/runtime/structured-agent-session-transport-overrides.ts +++ b/src/main/runtime/structured-agent-session-transport-overrides.ts @@ -3,7 +3,7 @@ import type { AcpStructuredSessionAdapterDeps } from '../acp/acp-structured-sess import type { AcpStructuredLaunchPartDeps } from '../acp/acp-structured-launch-resolution' import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter' import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter' -import type { readCodexCliInstallation } from '../preflight/codex-cli-installation' +import type { AgentCliVersionReader } from '../agent-cli-version-probe' import type { StructuredAgentSessionStartupLimits } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' import type { PiRpcSessionDeps } from '../pi/rpc-session' import type { PiRpcSessionAdapterDeps } from '../pi/rpc-session-adapter' @@ -17,7 +17,7 @@ export type StructuredAgentSessionTransportOverrides = { /** Independent discovery uses its own short-lived transport, separate from chat children. */ runCodexCatalogSession?: CodexModelCatalogProbeDeps['runSession'] /** Scripted transports must also replace the real host's version probe. */ - readCodexInstallation?: typeof readCodexCliInstallation + readCodexInstallation?: AgentCliVersionReader openAcpConnection?: AcpStructuredSessionAdapterDeps['connect'] /** Answers an ACP launch's command lookup and version probe for a scripted child; every launch * still resolves and checks through them. Unset, the real binary is found and probed. */ diff --git a/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts b/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts index a5c8587e44d..43d6dabf1ef 100644 --- a/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-availability-words-text.ts @@ -19,7 +19,7 @@ type AvailabilityCopyId = Extract< | 'agentNotSignedIn' | 'thenSendAgain' | 'cliMissing' - | 'codexCliTooOld' + | 'cliTooOld' > export const AVAILABILITY_PIECES: Record< @@ -78,6 +78,6 @@ export const AVAILABILITY_PIECES: Record< ), cliMissing: (values) => translate('components.native-chat.failureWords.cliMissing', COPY.cliMissing, values), - codexCliTooOld: (values) => - translate('components.native-chat.failureWords.codexCliTooOld', COPY.codexCliTooOld, values) + cliTooOld: (values) => + translate('components.native-chat.failureWords.cliTooOld', COPY.cliTooOld, values) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1df3d39c50b..ab76b071088 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -3074,7 +3074,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.", + "cliTooOld": "{{agent}} {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.", "codexManagedNotSignedIn": "This Codex account isn't signed in. Sign in again in Codex Accounts settings.", "codexSystemNotSignedIn": "Codex isn't signed in. Run `{{loginCommand}}`.", "commandAfterAnswer": "Answer the agent's question or approval, then run /{{command}}.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8c5bbf8f255..6b3a9630668 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18720,9 +18720,9 @@ "sendAgainToTryOnceMore": "Send your message again to try once more.", "couldNotStart": "{{agent}} couldn't start.", "couldNotRestart": "{{agent}} couldn't restart.", + "cliTooOld": "{{agent}} {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.", "startTimedOut": "{{agent}} took too long to start (no response to {{method}} after {{seconds}}s).", "startTimedOutKeepsHappening": "If this keeps happening, the computer running this chat may be overloaded.", - "codexCliTooOld": "Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.", "notSignedIn": "{{agent}} is not signed in.", "claudeSystemNotSignedIn": "Claude isn't signed in. Run `{{loginCommand}}`, or choose an account in Claude Accounts settings.", "claudeManagedNotSignedIn": "This Claude account isn't signed in. Sign in again in Claude Accounts settings.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4e35112c6e4..c952c2aebc4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18332,9 +18332,9 @@ "couldNotStart": "{{agent}} no pudo iniciarse.", "editSavedArguments": "Edítalos en Ajustes > Agentes > Argumentos.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", + "cliTooOld": "{{agent}} {{installedVersion}} es demasiado antiguo para los chats. Actualiza a {{minimumVersion}} o una versión posterior.", "startTimedOut": "{{agent}} tardó demasiado en iniciarse (sin respuesta a {{method}} después de {{seconds}} s).", "startTimedOutKeepsHappening": "Si esto sigue ocurriendo, es posible que el equipo que ejecuta este chat esté sobrecargado.", - "codexCliTooOld": "Codex {{installedVersion}} es demasiado antiguo para los chats. Actualiza a {{minimumVersion}} o una versión posterior.", "notSignedIn": "{{agent}} no ha iniciado sesión.", "claudeSystemNotSignedIn": "Claude no ha iniciado sesión. Ejecuta `{{loginCommand}}` o elige una cuenta en los ajustes de Cuentas de Claude.", "claudeManagedNotSignedIn": "Esta cuenta de Claude no ha iniciado sesión. Vuelve a iniciar sesión en la configuración de Cuentas de Claude.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5921272067e..6a6fbe5020e 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18441,9 +18441,9 @@ "couldNotStart": "{{agent}} n'a pas pu démarrer.", "editSavedArguments": "Modifiez-les dans Paramètres > Agents > Arguments.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", + "cliTooOld": "{{agent}} {{installedVersion}} est trop ancien pour les conversations. Mettez-le à jour vers {{minimumVersion}} ou une version ultérieure.", "startTimedOut": "{{agent}} a mis trop de temps à démarrer (aucune réponse à {{method}} après {{seconds}} s).", "startTimedOutKeepsHappening": "Si cela se reproduit, l'ordinateur qui exécute ce chat est peut-être surchargé.", - "codexCliTooOld": "Codex {{installedVersion}} est trop ancien pour les conversations. Mettez-le à jour vers {{minimumVersion}} ou une version ultérieure.", "notSignedIn": "{{agent}} n’est pas connecté.", "claudeSystemNotSignedIn": "Claude n’est pas connecté. Exécutez `{{loginCommand}}`, ou choisissez un compte dans les paramètres des Comptes Claude.", "claudeManagedNotSignedIn": "Ce compte Claude n’est pas connecté. Reconnectez-vous dans les paramètres des Comptes Claude.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1ac32cdc8f1..cfc6214020b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18372,9 +18372,9 @@ "couldNotStart": "{{agent}} を起動できませんでした。", "editSavedArguments": "設定 > Agent > 引数で編集してください。", "couldNotRestart": "{{agent}} を再起動できませんでした。", + "cliTooOld": "{{agent}} {{installedVersion}} はチャットには古すぎます。{{minimumVersion}} 以降に更新してください。", "startTimedOut": "{{agent}} の起動に時間がかかりすぎました ({{seconds}} 秒経っても {{method}} に応答がありません)。", "startTimedOutKeepsHappening": "これが繰り返し起こる場合は、このチャットを実行しているコンピューターが過負荷になっている可能性があります。", - "codexCliTooOld": "Codex {{installedVersion}} はチャットには古すぎます。{{minimumVersion}} 以降に更新してください。", "notSignedIn": "{{agent}} にサインインしていません。", "claudeSystemNotSignedIn": "Claude にサインインしていません。`{{loginCommand}}` を実行するか、Claude アカウント設定でアカウントを選択してください。", "claudeManagedNotSignedIn": "この Claude アカウントにサインインしていません。設定の Claude アカウントで再度サインインしてください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b15ba19c39f..79f4ce15c65 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18372,9 +18372,9 @@ "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", "editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", + "cliTooOld": "{{agent}} {{installedVersion}}은 채팅에 사용하기에는 너무 오래된 버전입니다. {{minimumVersion}} 이상으로 업데이트하세요.", "startTimedOut": "{{agent}}을(를) 시작하는 데 시간이 너무 오래 걸렸습니다({{seconds}}초 후에도 {{method}}에 응답이 없음).", "startTimedOutKeepsHappening": "이 문제가 계속되면 이 채팅을 실행하는 컴퓨터에 과부하가 걸렸을 수 있습니다.", - "codexCliTooOld": "Codex {{installedVersion}}은 채팅에 사용하기에는 너무 오래된 버전입니다. {{minimumVersion}} 이상으로 업데이트하세요.", "notSignedIn": "{{agent}}에 로그인되어 있지 않습니다.", "claudeSystemNotSignedIn": "Claude에 로그인되어 있지 않습니다. `{{loginCommand}}`을 실행하거나 Claude 계정 설정에서 계정을 선택하세요.", "claudeManagedNotSignedIn": "이 Claude 계정에 로그인하지 않았습니다. Claude 계정 설정에서 다시 로그인하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d631656c7a..ed7268f421b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18337,9 +18337,9 @@ "couldNotStart": "{{agent}} 无法启动。", "editSavedArguments": "请在设置 > 智能体 > 参数中编辑。", "couldNotRestart": "{{agent}} 无法重新启动。", + "cliTooOld": "{{agent}} {{installedVersion}} 版本过旧,无法用于聊天。请更新到 {{minimumVersion}} 或更高版本。", "startTimedOut": "{{agent}} 启动时间过长({{seconds}} 秒后 {{method}} 仍无响应)。", "startTimedOutKeepsHappening": "如果此问题持续出现,运行此聊天的计算机可能负载过重。", - "codexCliTooOld": "Codex {{installedVersion}} 版本过旧,无法用于聊天。请更新到 {{minimumVersion}} 或更高版本。", "notSignedIn": "{{agent}} 尚未登录。", "claudeSystemNotSignedIn": "Claude 尚未登录。请运行 `{{loginCommand}}`,或在 Claude 账户设置中选择账户。", "claudeManagedNotSignedIn": "此 Claude 账户尚未登录。请在 Claude 账户设置中重新登录。", diff --git a/src/shared/agent-cli-installation-problem.ts b/src/shared/agent-cli-installation-problem.ts new file mode 100644 index 00000000000..339ab70f37a --- /dev/null +++ b/src/shared/agent-cli-installation-problem.ts @@ -0,0 +1,49 @@ +import { isValidAppVersion } from './app-version' +import type { CodexInstallationProblem } from './codex-cli-installation' +import type { TuiAgent } from './tui-agent' +import { isTuiAgent } from './tui-agent-config' + +/** A chat start refused because the agent's binary is older than its chats need; names the agent + * so a replayed or phone refusal says whose without the chat. */ +export type AgentCliInstallationProblem = { + agent: TuiAgent + installedVersion: string + minimumVersion: string +} + +export function readAgentCliInstallationProblem( + value: unknown +): AgentCliInstallationProblem | undefined { + if ( + typeof value !== 'object' || + value === null || + !('agent' in value) || + !('installedVersion' in value) || + !('minimumVersion' in value) || + !isTuiAgent(value.agent) || + typeof value.installedVersion !== 'string' || + !isValidAppVersion(value.installedVersion) || + typeof value.minimumVersion !== 'string' || + !isValidAppVersion(value.minimumVersion) + ) { + return undefined + } + return { + agent: value.agent, + installedVersion: value.installedVersion, + minimumVersion: value.minimumVersion + } +} + +/** A refusal's facts for `problem`; Codex's are also sent the way clients before + * `agentInstallation` read them. */ +export function agentCliInstallationFacts(problem: AgentCliInstallationProblem): { + agentInstallation: AgentCliInstallationProblem + codexInstallation?: CodexInstallationProblem +} { + const { agent, installedVersion, minimumVersion } = problem + return { + agentInstallation: problem, + ...(agent === 'codex' ? { codexInstallation: { installedVersion, minimumVersion } } : {}) + } +} diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 6e09e5fe03f..04656243492 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -6,6 +6,8 @@ import { CLAUDE_PROFILE_SETUP_FAILED_MESSAGE } from './claude-profile-routing' import type { AgentSessionRefusalReference } from './agent-session-wire-refusals' +import type { AgentCliInstallationProblem } from './agent-cli-installation-problem' +import { tuiAgentDisplayName } from './tui-agent-display-names' /** 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.' @@ -28,11 +30,11 @@ 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.", + cliTooOld: + '{{agent}} {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer.', startTimedOut: '{{agent}} took too long to start (no response to {{method}} after {{seconds}}s).', startTimedOutKeepsHappening: 'If this keeps happening, the computer running this chat may be overloaded.', - 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.', @@ -193,21 +195,37 @@ export type AgentSessionFailureSay = ( values?: AgentSessionFailureCopyValues ) => string -export function codexInstallationFailureWords( +/** The sentence for an agent binary too old for chats. */ +export function agentCliTooOldWords( + { agent, installedVersion, minimumVersion }: AgentCliInstallationProblem, + say: AgentSessionFailureSay +): string { + return say('cliTooOld', { + agent: tuiAgentDisplayName(agent) ?? agent, + installedVersion, + minimumVersion + }) +} + +/** A refusal's installation sentence; a refusal saved before `agentInstallation` is Codex's. */ +export function agentCliInstallationFailureWords( 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 + if (refusal?.code !== 'agent_session_operation_invalid') { + return null + } + const { agentInstallation, codexInstallation } = refusal.details ?? {} + if (agentInstallation) { + return agentCliTooOldWords(agentInstallation, say) + } + if (!codexInstallation) { + return null + } + const { installedVersion, minimumVersion } = codexInstallation + return installedVersion === null + ? say('cliMissing', { agent: 'Codex' }) + : agentCliTooOldWords({ agent: 'codex', installedVersion, minimumVersion }, say) } /** The host's words, and any surface without translations. */ diff --git a/src/shared/agent-session-failure-words.test.ts b/src/shared/agent-session-failure-words.test.ts index f0eff4cdbff..7790c71a6bd 100644 --- a/src/shared/agent-session-failure-words.test.ts +++ b/src/shared/agent-session-failure-words.test.ts @@ -165,6 +165,33 @@ describe('the words written beside a failure fact', () => { ).toBe("Codex couldn't start. Start a new chat to continue.") }) + it("names a too-old agent's installed and minimum versions, and still reads Codex's legacy fact", () => { + const words = (details: Record) => + agentSessionFailureSentence( + readAgentSessionFailureFact({ + kind: 'startFailed', + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed', ...details } + } + })!, + 'row', + {} + ) + expect( + words({ + agentInstallation: { + agent: 'opencode', + installedVersion: '1.0.0', + minimumVersion: '1.18.31' + } + }) + ).toBe('OpenCode 1.0.0 is too old for chats. Update to 1.18.31 or newer.') + expect( + words({ codexInstallation: { installedVersion: '0.135.0', minimumVersion: '0.136.0' } }) + ).toBe('Codex 0.135.0 is too old for chats. Update to 0.136.0 or newer.') + }) + it('says a start refused beside a process Orca could not stop in its own words', () => { const refused = (context: { command?: 'compact'; retryControl?: boolean } = {}) => agentSessionFailureSentence( diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 650fecf5223..3ce2c414a52 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -18,7 +18,7 @@ import { import type { AgentSessionConversationCommand } from './agent-session-conversation-command' import { cliMissingSentence, notSignedInSentence } from './agent-session-availability-sentences' import { - codexInstallationFailureWords, + agentCliInstallationFailureWords, sayAgentSessionFailureEnglish, type AgentSessionFailureCopyId, type AgentSessionFailureCopyValues, @@ -134,7 +134,7 @@ function startRetry( function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { return (context, fact, _surface, say) => { - const installationFailure = codexInstallationFailureWords(fact.refusal, say) + const installationFailure = agentCliInstallationFailureWords(fact.refusal, say) if (installationFailure) { return installationFailure } diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index bd5bc9a4b01..c27dc672271 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -8,6 +8,10 @@ import type { AgentSessionAccountKind } from './agent-session-availability' import type { AgentJournalResolution } from './agent-session-journal-types' +import { + readAgentCliInstallationProblem, + type AgentCliInstallationProblem +} from './agent-cli-installation-problem' import { readCodexInstallationProblem, type CodexInstallationProblem @@ -179,6 +183,8 @@ type AgentSessionRefusalFactsByCode = { argumentProblem?: AgentSessionArgumentProblem /** The chat's agent a `stopFailed` Stop could not stop. */ agent?: TuiAgent + agentInstallation?: AgentCliInstallationProblem + /** Codex's binary problem as builds before `agentInstallation` send and read it. */ codexInstallation?: CodexInstallationProblem } agent_session_operation_unknown: RewindFacts @@ -306,6 +312,10 @@ export function readAgentSessionRefusalDetails { +describe('Codex CLI version', () => { 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 }) + ['codex-cli 0.135.0', '0.135.0'], + ['codex-cli v0.136.0', '0.136.0'], + ['0.136.0-alpha.2', '0.136.0-alpha.2'], + ['0.136.0+build.2', '0.136.0+build.2'], + ['codex-cli 0.153.4\n', '0.153.4'], + ['codex-cli unknown', null], + ['company-wrapper 0.1.0\ncodex-cli 0.136.0', '0.136.0'], + ['company-wrapper 9.0.0\ncodex-cli 0.135.0', '0.135.0'], + ['codex-cli 0.135.0\ncodex-cli 0.136.0', null], + ['wrapper 0.135.0', null], + ['0.135.0\n0.136.0', null], + ['', null] + ])('%j reads as %s', (output, version) => { + expect(parseCodexCliVersion(output)).toBe(version) }) it('admits only version facts that can safely be put in the refusal sentence', () => { diff --git a/src/shared/codex-cli-installation.ts b/src/shared/codex-cli-installation.ts index e388a78e76e..f1f6304c6eb 100644 --- a/src/shared/codex-cli-installation.ts +++ b/src/shared/codex-cli-installation.ts @@ -1,13 +1,8 @@ -import { compareAppVersions, isValidAppVersion, parseCliVersion } from './app-version' +import { 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 -} - +/** Codex's binary problem in the shape builds before `agentInstallation` send and save. */ export type CodexInstallationProblem = { installedVersion: string | null minimumVersion: string @@ -26,24 +21,6 @@ export function parseCodexCliVersion(output: string | null | undefined): string : 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' ||