From 349e3d19b9504cff466de44dc86f2fc42f1774a1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:57:10 -0700 Subject: [PATCH] Agents need only a minimum version for chat; newer releases and prereleases keep working (#30393) * fix(native-chat): agents need only a minimum version for chat; newer releases and prereleases run OpenCode chats accepted only the 1.x and 2.x lines and Pi only 0.x and 1.x, every prerelease was refused, and a slow or failed --version run counted as unsupported. A new major would open as a terminal and existing chats said "Orca can't run this agent in a chat here." Each agent now lists ascending minimum versions; a version must reach the latest one on or below its own major line, compared by semver. The check answers ready, too old, unknown (slow, failed or wordless run) or missing (did not start). Only too old refuses a launch, as Codex already did, with " X is too old for chats. Update to Y or newer." carried in a new optional refusal fact agentInstallation; Codex keeps sending codexInstallation for older clients. Codex's reader and error fold into the same check. * refactor(native-chat): a launch check declares its minimum versions; create support and the agent health snapshot run the one shared check * fix(native-chat): read an agent's version from stderr too, so Pi 0.73 is known too old instead of unreadable --- src/main/acp/acp-launch-specs.ts | 32 +++-- src/main/acp/acp-model-catalog-probe.test.ts | 10 +- src/main/acp/acp-model-catalog-probe.ts | 12 +- .../acp/acp-scripted-child.test-fixture.ts | 2 +- .../acp/acp-structured-context-clear.test.ts | 2 +- ...p-structured-launch-resolution-omp.test.ts | 31 ++--- ...uctured-launch-resolution-opencode.test.ts | 61 +++++---- .../acp/acp-structured-launch-resolution.ts | 53 +++----- .../acp/acp-structured-launch-visuals.test.ts | 2 +- src/main/acp/acp-visuals-skill.ts | 6 +- src/main/agent-cli-version-probe.test.ts | 118 ++++++++---------- src/main/agent-cli-version-probe.ts | 90 ++++++------- .../codex/codex-cli-installation-error.ts | 60 ++++----- src/main/codex/codex-minimum-version.test.ts | 65 +++++----- .../agent-cli-installation-error.ts | 37 ++++++ ...ent-session-codex-version-recovery.test.ts | 5 +- ...red-agent-session-failed-create-refusal.ts | 11 +- .../structured-agent-session-failure-text.ts | 9 +- src/main/pi/rpc-launch-resolution.test.ts | 27 ++-- src/main/pi/rpc-launch-resolution.ts | 41 +++--- src/main/pi/rpc-model-catalog-probe.test.ts | 4 +- src/main/pi/rpc-model-catalog-probe.ts | 8 +- src/main/pi/rpc-runtime-metadata.ts | 4 +- src/main/pi/rpc-runtime-registration.test.ts | 37 +++--- .../preflight/codex-cli-installation.test.ts | 15 ++- src/main/preflight/codex-cli-installation.ts | 14 --- .../codex-workspace-relative-path.test.ts | 7 +- .../scripted-codex-transport.test-fixture.ts | 7 +- .../structured-agent-availability.test.ts | 15 ++- .../runtime/structured-agent-availability.ts | 12 +- .../structured-agent-launch-support.test.ts | 49 ++++---- .../structured-agent-launch-support.ts | 24 ++-- ...ured-agent-model-catalog-discovery.test.ts | 5 +- .../structured-agent-runtime-metadata.ts | 6 +- ...ctured-agent-runtime-registration-types.ts | 4 +- ...tured-agent-session-transport-overrides.ts | 4 +- .../agent-session-availability-words-text.ts | 6 +- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- src/shared/agent-cli-installation-problem.ts | 49 ++++++++ src/shared/agent-session-failure-copy.ts | 46 ++++--- .../agent-session-failure-words.test.ts | 27 ++++ src/shared/agent-session-failure-words.ts | 4 +- src/shared/agent-session-refusal-details.ts | 11 ++ src/shared/agent-session-refusal-notice.ts | 2 +- src/shared/agent-session-visible-failures.ts | 19 ++- src/shared/agent-session-write-failure.ts | 3 + src/shared/codex-cli-installation.test.ts | 47 +++---- src/shared/codex-cli-installation.ts | 27 +--- 54 files changed, 598 insertions(+), 546 deletions(-) create mode 100644 src/main/native-chat/agent-cli-installation-error.ts delete mode 100644 src/main/preflight/codex-cli-installation.ts create mode 100644 src/shared/agent-cli-installation-problem.ts 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' ||