diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts index a462432167d..37c94deabac 100644 --- a/src/main/opencode/opencode-launch-model-context.ts +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -71,7 +71,7 @@ export function parseOpenCodeLaunchModelContext(options: { ) { return null } - // Installed 2.0.16 returns the selected default first; newer source has a different order. + // OpenCode 2.x `agent.list` returns the selected default agent first. const primary = agents.data.data.find((item) => item.mode !== 'subagent' && !item.hidden) const configured = config.data.reduce( (value, entry) => entry.info?.default_agent ?? value, diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts index fd27517d95b..607b54e0a77 100644 --- a/src/main/opencode/opencode-model-startup-plan.test.ts +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -77,25 +77,33 @@ describe('execution-host OpenCode model startup', () => { expect(probeOpenCodeLaunchCapabilities).not.toHaveBeenCalled() }) - it('launches current v2 in isolation and keeps one-time model preferences out of resume state', async () => { - const options = scope() - const plan = await buildExecutionHostAgentStartupPlan(options) - expect(plan?.launchCommand).toContain('--standalone') - expect(plan?.launchCommand).not.toContain('--model') - expect(plan?.sessionOptions).toEqual({ model: preferred }) - expect(JSON.parse(plan?.env?.OPENCODE_CONFIG_CONTENT ?? 'null')).toEqual({ - default_agent: 'reviewer', - model: preferred, - providers: { custom: { apiKey: 'placeholder' } }, - agents: { reviewer: { system: 'Review only', model: preferred } } - }) - expect(plan?.launchConfig).toEqual({ - agentCommand: 'opencode', - agentArgs: '', - agentEnv: { OPENCODE_CONFIG_CONTENT: original } - }) - expect(options.inputs.agentEnv.OPENCODE_CONFIG_CONTENT).toBe(original) - }) + it.each(['2.0.14', '2.0.16', '2.1.4'])( + 'launches v2 %s in isolation and keeps one-time model preferences out of resume state', + async (version) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version, + pluginApi: 'v2', + promptMode: 'prefill' + }) + const options = scope() + const plan = await buildExecutionHostAgentStartupPlan(options) + expect(plan?.launchCommand).toContain('--standalone') + expect(plan?.launchCommand).not.toContain('--model') + expect(plan?.sessionOptions).toEqual({ model: preferred }) + expect(JSON.parse(plan?.env?.OPENCODE_CONFIG_CONTENT ?? 'null')).toEqual({ + default_agent: 'reviewer', + model: preferred, + providers: { custom: { apiKey: 'placeholder' } }, + agents: { reviewer: { system: 'Review only', model: preferred } } + }) + expect(plan?.launchConfig).toEqual({ + agentCommand: 'opencode', + agentArgs: '', + agentEnv: { OPENCODE_CONFIG_CONTENT: original } + }) + expect(options.inputs.agentEnv.OPENCODE_CONFIG_CONTENT).toBe(original) + } + ) it('uses the selected account environment in both authoritative probes', async () => { vi.mocked(applyManagedDataAccountEnvironment).mockImplementation((env) => { @@ -111,7 +119,7 @@ describe('execution-host OpenCode model startup', () => { } }) - it.each(['1.18.30', '1.18.32'])( + it.each(['1.18.30', '1.18.35', '1.19.4'])( 'preserves the verified legacy launch route %s', async (version) => { vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ @@ -179,7 +187,7 @@ describe('execution-host OpenCode model startup', () => { expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() }) - it.each(['1.18.31', '1.18.33', '2.0.17'])( + it.each(['1.18.29', '2.0.13', '2.1.5-beta.1', '3.0.0'])( 'refuses unverified version %s before model probing', async (version) => { vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts index 84b0599ce88..ad3ec200516 100644 --- a/src/main/opencode/opencode-model-startup-plan.ts +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -10,7 +10,10 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resum import type { TuiAgent } from '../../shared/tui-agent' import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' -import { isVerifiedOpenCodeLegacyModelVersion } from './opencode-model-version-policy' +import { + isVerifiedOpenCodeConfigModelVersion, + isVerifiedOpenCodeLegacyModelVersion +} from './opencode-model-version-policy' import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { OrchestrationError } from '../runtime/orchestration/orchestration-error' @@ -92,7 +95,7 @@ export async function prepareOpenCodeModelStartupInputs( } return { inputs } } - if (capabilities?.version !== '2.0.16' || inputs.agentArgs?.trim()) { + if (!isVerifiedOpenCodeConfigModelVersion(capabilities?.version) || inputs.agentArgs?.trim()) { refuseModel() } const before = await probeOpenCodeLaunchModelContext({ diff --git a/src/main/opencode/opencode-model-version-policy.ts b/src/main/opencode/opencode-model-version-policy.ts index e741a50f89e..48427ef52d9 100644 --- a/src/main/opencode/opencode-model-version-policy.ts +++ b/src/main/opencode/opencode-model-version-policy.ts @@ -1,6 +1,23 @@ -const VERIFIED_LEGACY_MODEL_VERSIONS = new Set(['1.18.30', '1.18.32']) +import { compareAppVersions } from '../../shared/app-version' + +// Real-CLI-verified floors, capped to their major: a new major may change the model route. +const LEGACY_FLAG_MODEL_FLOOR = '1.18.30' +const CONFIG_OVERLAY_MODEL_FLOOR = '2.0.14' + +function isStableReleaseFrom(version: string | null | undefined, floor: string): boolean { + const major = floor.split('.')[0] + return ( + typeof version === 'string' && + /^\d+\.\d+\.\d+$/.test(version) && + version.split('.')[0] === major && + compareAppVersions(version, floor) >= 0 + ) +} export function isVerifiedOpenCodeLegacyModelVersion(version: string | null | undefined): boolean { - // Plugin API compatibility alone does not verify model selection or invalid-model behavior. - return typeof version === 'string' && VERIFIED_LEGACY_MODEL_VERSIONS.has(version) + return isStableReleaseFrom(version, LEGACY_FLAG_MODEL_FLOOR) +} + +export function isVerifiedOpenCodeConfigModelVersion(version: string | null | undefined): boolean { + return isStableReleaseFrom(version, CONFIG_OVERLAY_MODEL_FLOOR) }