mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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 "<Agent> 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
This commit is contained in:
@@ -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<AcpSignInWatch, 'link'>
|
||||
/** 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<Record<string, string>>; 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<AcpLaunchSpec, 'agent' | 'command' | 'supportsVersion' | 'modelDiscovery'>
|
||||
row: Pick<AcpLaunchSpec, 'agent' | 'command' | 'minimumVersions' | 'modelDiscovery'>
|
||||
): 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).
|
||||
|
||||
@@ -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<NonNullable<AcpModelCatalogProbeDeps['runListing']>>(),
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: {}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<Parameters<typeof createAcpStructuredLaunchResolver>[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 }])
|
||||
})
|
||||
|
||||
@@ -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<Parameters<typeof createAcpStructuredLaunchResolver>[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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<AcpLaunchVisualsDeps, 'logger'>
|
||||
@@ -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<string, string> },
|
||||
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<AgentCliVersionCheck> {
|
||||
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<string, string> },
|
||||
probe: typeof probeAgentCliVersion = probeAgentCliVersion
|
||||
): Promise<boolean> {
|
||||
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, {
|
||||
|
||||
@@ -44,7 +44,7 @@ function resolve(
|
||||
const spec = { ...acpLaunchSpecFor(agent)!, ...options.spec }
|
||||
const probeVersion = vi.fn<
|
||||
NonNullable<Parameters<typeof createAcpStructuredLaunchResolver>[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 }
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}): void {
|
||||
runProcess.mockResolvedValue({
|
||||
@@ -23,14 +18,15 @@ function prints(stdout: string, result: Record<string, unknown> = {}): 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<program> --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<ProcessSpec, 'program' | 'cwd' | 'env'>
|
||||
) => Promise<{ version: string | null; started?: false }>
|
||||
|
||||
/** Runs `<program> --version` on this host and holds what it prints to `minimumVersions`. */
|
||||
export async function checkAgentCliVersion(
|
||||
input: Pick<ProcessSpec, 'program' | 'cwd' | 'env'>,
|
||||
supports: (version: string) => boolean
|
||||
): Promise<AgentCliVersionObservation> {
|
||||
const result = await readAgentCliVersion(input)
|
||||
if (result.version === null) {
|
||||
return 'inconclusive'
|
||||
minimumVersions: readonly string[],
|
||||
read: AgentCliVersionReader = readAgentCliVersion
|
||||
): Promise<AgentCliVersionCheck> {
|
||||
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<ProcessSpec, 'program' | 'cwd' | 'env'>,
|
||||
supports: (version: string) => boolean
|
||||
): Promise<boolean> {
|
||||
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<ProcessSpec, 'program' | 'cwd' | 'env'>,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<typeof readCodexCliInstallation>[0],
|
||||
readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation
|
||||
input: Parameters<AgentCliVersionReader>[0],
|
||||
readVersion: AgentCliVersionReader = readCodexCliVersion
|
||||
): Promise<void> {
|
||||
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<L extends ProviderProcessLaunch>(
|
||||
resolveLaunch: StructuredAgentLaunchResolver<L>,
|
||||
readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation
|
||||
readVersion: AgentCliVersionReader = readCodexCliVersion
|
||||
): StructuredAgentLaunchResolver<L> {
|
||||
return async (request) => {
|
||||
const launch = await resolveLaunch(request)
|
||||
@@ -62,7 +44,7 @@ export function withSupportedCodexCli<L extends ProviderProcessLaunch>(
|
||||
cwd: launch.cwd,
|
||||
env: resolveProviderChildEnv(launch, process.env)
|
||||
},
|
||||
readInstallation
|
||||
readVersion
|
||||
)
|
||||
return launch
|
||||
}
|
||||
|
||||
@@ -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<AgentCliVersionReader>()
|
||||
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()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -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'
|
||||
})
|
||||
|
||||
+6
-5
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string> },
|
||||
probe: typeof probeAgentCliVersion = probeAgentCliVersion
|
||||
): Promise<boolean> {
|
||||
return probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, supportsPiRpcVersion)
|
||||
read?: AgentCliVersionReader
|
||||
): Promise<AgentCliVersionCheck> {
|
||||
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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof readCodexCliVersion>[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')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<ProcessSpec, 'program' | 'cwd' | 'env'>
|
||||
): Promise<CodexCliInstallation> {
|
||||
const result = await readAgentCliVersion(input, parseCodexCliVersion)
|
||||
return codexCliInstallation(true, result.version)
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>()
|
||||
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<void>((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<void>()
|
||||
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<void>((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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof vi.fn<() => Promise<NodeJS.ProcessEnv | null>>>
|
||||
@@ -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<NodeJS.ProcessEnv | null> => loginShell.env)
|
||||
}
|
||||
return { observeAgentCliVersion: vi.fn(), probeAgentCliVersion: vi.fn(), loginShell }
|
||||
return { readVersion: vi.fn<VersionProbeModule.AgentCliVersionReader>(), loginShell }
|
||||
})
|
||||
vi.mock('../agent-cli-version-probe', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof VersionProbeModule>()
|
||||
return {
|
||||
...real,
|
||||
checkAgentCliVersion: (...[input, minimums]: Parameters<typeof real.checkAgentCliVersion>) =>
|
||||
real.checkAgentCliVersion(input, minimums, readVersion)
|
||||
}
|
||||
})
|
||||
vi.mock('../agent-cli-version-probe', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
observeAgentCliVersion,
|
||||
probeAgentCliVersion
|
||||
}))
|
||||
vi.mock('../startup/login-shell-environment', async (importOriginal) => {
|
||||
const real = await importOriginal<typeof LoginShellModule>()
|
||||
return {
|
||||
@@ -43,16 +47,11 @@ vi.mock('../startup/login-shell-environment', async (importOriginal) => {
|
||||
})
|
||||
|
||||
let bin: string
|
||||
const asked: { program: string; cwd: string; env: Record<string, string> }[] = []
|
||||
const asked: Parameters<VersionProbeModule.AgentCliVersionReader>[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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string>,
|
||||
runtime: LaunchSettingsRuntime,
|
||||
refreshShellOnMiss: boolean
|
||||
): Promise<AgentCliVersionObservation> {
|
||||
): Promise<AgentCliVersionCheck | null> {
|
||||
const check = structuredAgentRuntimeMetadata(agent)?.launchCheck
|
||||
if (!check) {
|
||||
return 'supported'
|
||||
return null
|
||||
}
|
||||
const settings = runtime.requireStore().getSettings()
|
||||
let directory: Promise<string> | 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<string>,
|
||||
runtime: LaunchSettingsRuntime
|
||||
): Promise<boolean> {
|
||||
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)) {
|
||||
|
||||
@@ -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<object>()),
|
||||
checkAgentCliVersion: async (input: { program: string }) => {
|
||||
spawned.commands.push(input.program)
|
||||
throw new Error('no spawn in this test')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -73,8 +73,8 @@ export type StructuredAgentLaunchCheck = {
|
||||
env: Record<string, string>,
|
||||
commandSettings: StructuredAgentCommandSettings
|
||||
) => { command: string; env: Record<string, string> }
|
||||
/** 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 & {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}}.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 アカウントで再度サインインしてください。",
|
||||
|
||||
@@ -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 계정 설정에서 다시 로그인하세요.",
|
||||
|
||||
@@ -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 账户设置中重新登录。",
|
||||
|
||||
@@ -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 } } : {})
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<C extends AgentSessionWireRefusal
|
||||
code === 'agent_session_operation_invalid'
|
||||
? readAgentSessionArgumentProblem(value.argumentProblem)
|
||||
: undefined
|
||||
const agentInstallation =
|
||||
code === 'agent_session_operation_invalid'
|
||||
? readAgentCliInstallationProblem(value.agentInstallation)
|
||||
: undefined
|
||||
const codexInstallation =
|
||||
code === 'agent_session_operation_invalid'
|
||||
? readCodexInstallationProblem(value.codexInstallation)
|
||||
@@ -335,6 +345,7 @@ export function readAgentSessionRefusalDetails<C extends AgentSessionWireRefusal
|
||||
isNamedAgent(value.agent)
|
||||
? { agent: value.agent }
|
||||
: {}),
|
||||
...(agentInstallation ? { agentInstallation } : {}),
|
||||
...(codexInstallation ? { codexInstallation } : {})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reason was checked against `code`'s list and only the facts `code` lists (plus the verdict every code may carry) were kept.
|
||||
|
||||
@@ -103,7 +103,7 @@ function reasonParts(
|
||||
): AgentSessionWriteNoticePart[] | undefined {
|
||||
if (
|
||||
failure.code === 'agent_session_operation_invalid' &&
|
||||
failure.details?.codexInstallation &&
|
||||
(failure.details?.agentInstallation || failure.details?.codexInstallation) &&
|
||||
(write === 'send' || write === 'composer-send')
|
||||
) {
|
||||
return [
|
||||
|
||||
@@ -24,6 +24,13 @@ export function agentSessionVisibleFailureFacts(
|
||||
return facts
|
||||
}
|
||||
|
||||
function sameInstallation(
|
||||
a: { installedVersion: string | null; minimumVersion: string } | undefined,
|
||||
b: { installedVersion: string | null; minimumVersion: string } | undefined
|
||||
): boolean {
|
||||
return a?.installedVersion === b?.installedVersion && a?.minimumVersion === b?.minimumVersion
|
||||
}
|
||||
|
||||
/** Whether two facts are one failure: a start's row and the messages it rejected share one. */
|
||||
export function sameAgentSessionFailureFact(
|
||||
a: AgentSessionFailureFact,
|
||||
@@ -38,10 +45,14 @@ export function sameAgentSessionFailureFact(
|
||||
a.refusal?.details?.reason === b.refusal?.details?.reason &&
|
||||
(a.refusal?.code !== 'agent_session_operation_invalid' ||
|
||||
b.refusal?.code !== 'agent_session_operation_invalid' ||
|
||||
(a.refusal.details?.codexInstallation?.installedVersion ===
|
||||
b.refusal.details?.codexInstallation?.installedVersion &&
|
||||
a.refusal.details?.codexInstallation?.minimumVersion ===
|
||||
b.refusal.details?.codexInstallation?.minimumVersion)) &&
|
||||
(sameInstallation(
|
||||
a.refusal.details?.agentInstallation,
|
||||
b.refusal.details?.agentInstallation
|
||||
) &&
|
||||
sameInstallation(
|
||||
a.refusal.details?.codexInstallation,
|
||||
b.refusal.details?.codexInstallation
|
||||
))) &&
|
||||
a.attachment?.reason === b.attachment?.reason &&
|
||||
a.attachment?.limit === b.attachment?.limit &&
|
||||
a.retry?.error === b.retry?.error &&
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { AgentSessionRewindReason } from './agent-session-rewind'
|
||||
import type { AgentSessionArgumentProblem } from './agent-session-argument-problem'
|
||||
import type { TuiAgent } from './tui-agent'
|
||||
import type { CodexInstallationProblem } from './codex-cli-installation'
|
||||
import type { AgentCliInstallationProblem } from './agent-cli-installation-problem'
|
||||
import {
|
||||
isAgentSessionWireRefusalCode,
|
||||
readAgentSessionRefusalReference,
|
||||
@@ -84,6 +85,7 @@ type DurableRefusalFacts = {
|
||||
account?: AgentSessionAccountKind
|
||||
argumentProblem?: AgentSessionArgumentProblem
|
||||
agent?: TuiAgent
|
||||
agentInstallation?: AgentCliInstallationProblem
|
||||
codexInstallation?: CodexInstallationProblem
|
||||
}
|
||||
agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason }
|
||||
@@ -97,6 +99,7 @@ const DURABLE_FACT_KEYS: Partial<Record<AgentSessionWireRefusalCode, readonly st
|
||||
'account',
|
||||
'argumentProblem',
|
||||
'agent',
|
||||
'agentInstallation',
|
||||
'codexInstallation'
|
||||
],
|
||||
agent_session_operation_unknown: ['rewindReason'],
|
||||
|
||||
@@ -1,37 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CODEX_MINIMUM_SUPPORTED_VERSION,
|
||||
codexCliInstallation,
|
||||
readCodexInstallationProblem
|
||||
} from './codex-cli-installation'
|
||||
import { parseCodexCliVersion, readCodexInstallationProblem } from './codex-cli-installation'
|
||||
|
||||
describe('Codex structured chat version floor', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
Reference in New Issue
Block a user