diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index efeccd1c10e..fdc5f4c2e41 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -1267,7 +1267,6 @@ 1 test-timeout-needs-reason:src/main/native-chat/agent-session-wire/structured-agent-session-interrupted-continuation.test.ts 1 test-timeout-needs-reason:src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-children.test.ts 1 test-timeout-needs-reason:src/main/notebook/notebook-kernel.test.ts -1 test-timeout-needs-reason:src/main/opencode/opencode-launch-model-context.test.ts 1 test-timeout-needs-reason:src/main/orcad/orcad-bundle-native-load-order.test.ts 1 test-timeout-needs-reason:src/main/orcad/orcad-packaged-node-pty.integration.test.ts 1 test-timeout-needs-reason:src/main/orcad/orcad-packaged-structured-chat.integration.test.ts diff --git a/src/main/ipc/pty/pty-launch-parity.test.ts b/src/main/ipc/pty/pty-launch-parity.test.ts index 972f129f130..0ec269e7eea 100644 --- a/src/main/ipc/pty/pty-launch-parity.test.ts +++ b/src/main/ipc/pty/pty-launch-parity.test.ts @@ -43,7 +43,7 @@ import { paneSpawnReservationsByOwnerKey } from './pane/spawn-reservation' import { makePaneKey } from '../../../shared/stable-pane-id' -import { CALLER_LAUNCH_CASES, OPENCODE_MODEL } from '../pty-launch-parity-caller.test-cases' +import { CALLER_LAUNCH_CASES } from '../pty-launch-parity-caller.test-cases' import { RpcDispatcher } from '../../runtime/rpc/dispatcher' import { DESKTOP_RPC_CALLER } from '../../runtime/rpc/rpc-caller-identity' import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../desktop-renderer-runtime-capabilities' @@ -51,10 +51,7 @@ import { TERMINAL_LIFECYCLE_METHODS } from '../../runtime/rpc/methods/terminal/t import { AGENT_SESSION_METHODS } from '../../runtime/rpc/methods/agent-session' import { applyManagedDataAccountEnvironment } from '../../managed-data-accounts/launch-environment' import { probeOpenCodeLaunchCapabilities } from '../../opencode/opencode-launch-capabilities' -import { - probeOpenCodeModelAvailability, - resolveOpenCodeDirectModelExecutable -} from '../../opencode/opencode-model-availability' +import { resolveOpenCodeDirectModelExecutable } from '../../opencode/opencode-model-executable' import { probeOpenCodeLaunchModelContext } from '../../opencode/opencode-launch-model-context' import { readOpenSshDefaultShell } from '../../../shared/openssh-default-shell' @@ -108,9 +105,8 @@ vi.mock('../../managed-data-accounts/launch-environment', () => ({ vi.mock('../../opencode/opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) -vi.mock('../../opencode/opencode-model-availability', () => ({ - resolveOpenCodeDirectModelExecutable: vi.fn(), - probeOpenCodeModelAvailability: vi.fn() +vi.mock('../../opencode/opencode-model-executable', () => ({ + resolveOpenCodeDirectModelExecutable: vi.fn() })) vi.mock('../../opencode/opencode-launch-model-context', () => ({ probeOpenCodeLaunchModelContext: vi.fn() @@ -176,19 +172,9 @@ function stubOpenCodeV2(): void { vi.mocked(probeOpenCodeLaunchCapabilities) .mockReset() .mockResolvedValue({ version: '2.0.16', pluginApi: 'v2', promptMode: 'prefill' }) - vi.mocked(probeOpenCodeModelAvailability).mockReset().mockResolvedValue(true) vi.mocked(probeOpenCodeLaunchModelContext) .mockReset() - .mockResolvedValueOnce({ - primaryAgent: 'build', - availableModels: [OPENCODE_MODEL], - primaryModel: 'opencode/big-pickle' - }) - .mockResolvedValue({ - primaryAgent: 'build', - availableModels: [OPENCODE_MODEL], - primaryModel: OPENCODE_MODEL - }) + .mockResolvedValue({ primaryAgent: 'build' }) } async function runHostCall(lanes: ParityLanes, c: HostLaunchCase): Promise { diff --git a/src/main/opencode/opencode-launch-model-config.test.ts b/src/main/opencode/opencode-launch-model-config.test.ts index 65d183e156f..5a503a13ddf 100644 --- a/src/main/opencode/opencode-launch-model-config.test.ts +++ b/src/main/opencode/opencode-launch-model-config.test.ts @@ -57,7 +57,7 @@ describe('per-launch OpenCode model config', () => { } ) - it('refuses oversized config and absent resolved preferences', () => { + it('refuses oversized config and a missing requested model', () => { expect( resolveOpenCodeLaunchModelConfig({ configContent: ' '.repeat(1_048_577), @@ -68,8 +68,8 @@ describe('per-launch OpenCode model config', () => { expect( resolveOpenCodeLaunchModelConfig({ configContent: '{}', - primaryAgent: '', - model: 'provider/a' + primaryAgent: 'build', + model: '' }) ).toBeNull() }) diff --git a/src/main/opencode/opencode-launch-model-config.ts b/src/main/opencode/opencode-launch-model-config.ts index c8a1d75f5ae..f20554385e2 100644 --- a/src/main/opencode/opencode-launch-model-config.ts +++ b/src/main/opencode/opencode-launch-model-config.ts @@ -6,10 +6,10 @@ const launchConfig = z export function resolveOpenCodeLaunchModelConfig(options: { configContent: string | undefined - primaryAgent: string + primaryAgent?: string model: string }): string | null { - if (!options.primaryAgent || !options.model || (options.configContent?.length ?? 0) > 1_048_576) { + if (!options.model || (options.configContent?.length ?? 0) > 1_048_576) { return null } try { @@ -19,13 +19,18 @@ export function resolveOpenCodeLaunchModelConfig(options: { return null } const config = parsed.data + const primaryAgent = options.primaryAgent return JSON.stringify({ ...config, model: options.model, - agents: { - ...config.agents, - [options.primaryAgent]: { ...config.agents?.[options.primaryAgent], model: options.model } - } + ...(primaryAgent + ? { + agents: { + ...config.agents, + [primaryAgent]: { ...config.agents?.[primaryAgent], model: options.model } + } + } + : {}) }) } catch { return null diff --git a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts index 6e5f034c947..c0d66b5824a 100644 --- a/src/main/opencode/opencode-launch-model-context-cleanup.test.ts +++ b/src/main/opencode/opencode-launch-model-context-cleanup.test.ts @@ -102,7 +102,7 @@ describe('OpenCode model probe termination evidence', () => { .enum(['model', 'agent', 'model/default', 'config']) .parse(input.pathname.slice('/api/'.length)) const response = snapshot[endpoint] - if (closeDuringFetch && endpoint === 'config') { + if (closeDuringFetch && endpoint === 'agent') { queueMicrotask(close) } return new Response(JSON.stringify(response), { status: 200 }) @@ -137,7 +137,7 @@ describe('OpenCode model probe termination evidence', () => { vi.fn(async () => new Response(new ReadableStream({ cancel }), { status: 200 })) ) expect(await probeOpenCodeLaunchModelContext(options)).toBeNull() - expect(cancel).toHaveBeenCalledTimes(4) + expect(cancel).toHaveBeenCalledTimes(1) expect(child.kill).toHaveBeenCalledWith('SIGTERM') }) diff --git a/src/main/opencode/opencode-launch-model-context-supervision.test.ts b/src/main/opencode/opencode-launch-model-context-supervision.test.ts index ca5980c4d4f..3cb4e9bb616 100644 --- a/src/main/opencode/opencode-launch-model-context-supervision.test.ts +++ b/src/main/opencode/opencode-launch-model-context-supervision.test.ts @@ -62,9 +62,7 @@ describe.runIf(process.platform !== 'win32')('supervised OpenCode launch-context const pids = await rig.readPids() expect(context).toEqual({ - primaryAgent: 'build', - availableModels: ['provider/model'], - primaryModel: null + primaryAgent: 'build' }) expect(existsSync(rig.signalFile) && readFileSync(rig.signalFile, 'utf8')).toBe('SIGTERM') expect(alive(pids.provider)).toBe(false) diff --git a/src/main/opencode/opencode-launch-model-context.test.ts b/src/main/opencode/opencode-launch-model-context.test.ts index faf19bfdedf..fa4615d5a36 100644 --- a/src/main/opencode/opencode-launch-model-context.test.ts +++ b/src/main/opencode/opencode-launch-model-context.test.ts @@ -1,75 +1,23 @@ -import { mkdtemp, mkdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { - parseOpenCodeLaunchModelContext, - probeOpenCodeLaunchModelContext -} from './opencode-launch-model-context' +import { parseOpenCodeLaunchModelContext } from './opencode-launch-model-context' import { resolveOpenCodeLaunchModelConfig } from './opencode-launch-model-config' +import recordedCases from './opencode-resolved-agents.fixture.json' const directory = '/private/project' const snapshot = { directory, - models: { - location: { directory }, - data: [ - { id: 'one', providerID: 'provider', enabled: true }, - { id: 'disabled', providerID: 'provider', enabled: false } - ] - }, agents: { location: { directory }, data: [ - { - id: 'reviewer', - mode: 'primary', - hidden: false, - model: { id: 'one', providerID: 'provider' } - }, { id: 'build', mode: 'primary', hidden: false }, - { id: 'hidden', mode: 'primary', hidden: true }, - { id: 'child', mode: 'subagent', hidden: false } + { id: 'reviewer', mode: 'primary' } ] - }, - config: [{ type: 'document', info: { default_agent: 'reviewer' } }], - defaultModel: { - location: { directory }, - data: { id: 'one', providerID: 'provider', enabled: true } } } -describe('OpenCode execution-host model context', () => { - it('uses the configured visible primary and exact enabled provider/model pairs', () => { - expect(parseOpenCodeLaunchModelContext(snapshot)).toEqual({ - primaryAgent: 'reviewer', - availableModels: ['provider/one'], - primaryModel: 'provider/one' - }) - }) - - it('uses actual returned primary ordering and skips hidden agents and children', () => { - const agents = { - location: { directory }, - data: [...snapshot.agents.data.slice(2), ...snapshot.agents.data.slice(0, 2)] - } - expect(parseOpenCodeLaunchModelContext({ ...snapshot, agents })?.primaryAgent).toBe('reviewer') - expect( - parseOpenCodeLaunchModelContext({ - ...snapshot, - defaultModel: { location: { directory }, data: null } - }) - ).toBeNull() - }) - - it('refuses an uninitialized, malformed or foreign-location response', () => { - for (const models of [ - null, - { location: { directory }, data: [] }, - { location: { directory: '/other' }, data: snapshot.models.data } - ]) { - expect(parseOpenCodeLaunchModelContext({ ...snapshot, models })).toBeNull() - } +describe('OpenCode execution-host primary-agent context', () => { + it('uses the resolved first selectable primary and leaves absent identity unknown', () => { + expect(parseOpenCodeLaunchModelContext(snapshot)).toEqual({ primaryAgent: 'build' }) expect( parseOpenCodeLaunchModelContext({ ...snapshot, @@ -77,66 +25,77 @@ describe('OpenCode execution-host model context', () => { }) ).toBeNull() }) -}) -describe.skipIf(!process.env.ORCA_REAL_OPENCODE_CLI_TEST)( - 'real current OpenCode model preflight', - () => { - it('waits for initialization and verifies a custom primary before and after a private overlay', async () => { - const executable = process.env.ORCA_REAL_OPENCODE_CLI_TEST - if (!executable) { - throw new Error('Real OpenCode executable required') - } - const home = await mkdtemp(join(tmpdir(), 'orca-opencode-model-context-')) - try { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!/API_KEY|TOKEN|SECRET|AUTH|CREDENTIAL|OPENCODE_|ORCA_/.test(key)) { - env[key] = value - } + it('uses recorded 2.0.19 resolved agents without inventing stale or disabled primaries', () => { + for (const capture of recordedCases) { + const original = JSON.parse(capture.configContent) + const primaryAgent = capture.name === 'stale-default' ? 'build' : 'plan' + const context = parseOpenCodeLaunchModelContext({ + directory: '/qa/project', + agents: capture.settled + }) + expect(context).toEqual({ primaryAgent }) + expect( + JSON.parse( + resolveOpenCodeLaunchModelConfig({ + configContent: capture.configContent, + primaryAgent: context?.primaryAgent, + model: capture.model + }) ?? 'null' + ) + ).toEqual({ + ...original, + model: capture.model, + agents: { ...original.agents, [primaryAgent]: { model: capture.model } } + }) + + const initialContext = parseOpenCodeLaunchModelContext({ + directory: '/qa/project', + agents: capture.initial + }) + expect(initialContext).toBeNull() + expect( + JSON.parse( + resolveOpenCodeLaunchModelConfig({ + configContent: capture.configContent, + primaryAgent: initialContext?.primaryAgent, + model: capture.model + }) ?? 'null' + ) + ).toEqual({ ...original, model: capture.model }) + expect( + resolveOpenCodeLaunchModelConfig({ + configContent: undefined, + primaryAgent: initialContext?.primaryAgent, + model: capture.model + }) + ).toBe(JSON.stringify({ model: capture.model })) + } + }) + + it('tolerates absent model fields and unrelated agent records', () => { + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + agents: { + location: { directory }, + data: [ + { unrelated: true }, + { id: 'hidden', hidden: true }, + { id: 'child', mode: 'subagent' }, + { id: 'reviewer', mode: 'primary', model: null } + ] } - for (const [key, child] of [ - ['HOME', 'home'], - ['XDG_CONFIG_HOME', 'config'], - ['XDG_DATA_HOME', 'data'], - ['XDG_STATE_HOME', 'state'], - ['XDG_CACHE_HOME', 'cache'] - ]) { - const path = join(home, child) - await mkdir(path, { mode: 0o700 }) - env[key] = path - } - env.ORCA_BACKGROUND_LAUNCH = '1' - env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ - default_agent: 'reviewer', - model: 'opencode/big-pickle', - agents: { - reviewer: { mode: 'primary', system: 'Review only', model: 'opencode/big-pickle' } - } - }) - const options = { executable, cwd: process.cwd(), env } - const before = await probeOpenCodeLaunchModelContext(options) - expect(before?.primaryAgent).toBe('reviewer') - expect(before?.primaryModel).toBe('opencode/big-pickle') - expect(before?.availableModels).toContain('opencode/fledge-alpha-free') - const config = resolveOpenCodeLaunchModelConfig({ - configContent: env.OPENCODE_CONFIG_CONTENT, - primaryAgent: before?.primaryAgent ?? '', - model: 'opencode/fledge-alpha-free' - }) - expect(config).not.toBeNull() - const after = await probeOpenCodeLaunchModelContext({ - ...options, - env: { ...env, OPENCODE_CONFIG_CONTENT: config ?? '' }, - expectedPrimaryAgent: 'reviewer', - expectedPrimaryModel: 'opencode/fledge-alpha-free' - }) - expect(after?.primaryAgent).toBe('reviewer') - expect(after?.primaryModel).toBe('opencode/fledge-alpha-free') - expect(env.OPENCODE_CONFIG_CONTENT).toContain('opencode/big-pickle') - } finally { - await rm(home, { recursive: true, force: true }) - } - }, 30_000) - } -) + }) + ).toEqual({ primaryAgent: 'reviewer' }) + }) + + it('never uses primary-agent data from a foreign location', () => { + expect( + parseOpenCodeLaunchModelContext({ + ...snapshot, + agents: { ...snapshot.agents, location: { directory: '/other' } } + }) + ).toBeNull() + }) +}) diff --git a/src/main/opencode/opencode-launch-model-context.ts b/src/main/opencode/opencode-launch-model-context.ts index 015eae9cb83..08d2dc8854e 100644 --- a/src/main/opencode/opencode-launch-model-context.ts +++ b/src/main/opencode/opencode-launch-model-context.ts @@ -18,81 +18,33 @@ import { guardSupervisedProviderGroup } from '../provider-process/provider-group const OPENCODE_PREFLIGHT_KILL_SITE = 'opencode-launch-model-preflight' const SUPERVISOR_PIPE_CLOSE_MS = 1_000 -const model = z.object({ id: z.string(), providerID: z.string() }) -const availableModel = model.extend({ enabled: z.boolean() }) const agent = z.object({ - id: z.string(), - mode: z.enum(['primary', 'subagent', 'all']), - hidden: z.boolean(), - model: model.optional() + id: z.string().min(1), + mode: z.string().optional(), + hidden: z.boolean().optional() }) const location = z.object({ directory: z.string() }) -const modelResponse = z.object({ location, data: availableModel.array() }) -const agentResponse = z.object({ location, data: agent.array() }) -const defaultModelResponse = z.object({ - location, - data: availableModel.nullable() -}) -const configResponse = z.array( - z.object({ - type: z.enum(['directory', 'document']), - info: z.object({ default_agent: z.string().optional() }).optional() - }) -) +const agentResponse = z.object({ location, data: z.unknown().array() }) export type OpenCodeLaunchModelContext = { primaryAgent: string - availableModels: string[] - primaryModel: string | null } export function parseOpenCodeLaunchModelContext(options: { directory: string - models: unknown agents: unknown - defaultModel: unknown - config: unknown }): OpenCodeLaunchModelContext | null { - const models = modelResponse.safeParse(options.models) const agents = agentResponse.safeParse(options.agents) - const defaultModel = defaultModelResponse.safeParse(options.defaultModel) - const config = configResponse.safeParse(options.config) - if ( - !models.success || - !agents.success || - !defaultModel.success || - !config.success || - models.data.location.directory !== options.directory || - agents.data.location.directory !== options.directory || - defaultModel.data.location.directory !== options.directory || - !defaultModel.data.data?.enabled || - agents.data.data.length === 0 - ) { + if (agents.success && agents.data.location.directory !== options.directory) { return null } - // 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, - undefined - ) - const available = models.data.data.filter((item) => item.enabled) - if ( - !primary || - (configured !== undefined && primary.id !== configured) || - !available.some( - (item) => - item.id === defaultModel.data.data?.id && - item.providerID === defaultModel.data.data?.providerID - ) - ) { - return null - } - return { - primaryAgent: primary.id, - availableModels: available.map((item) => `${item.providerID}/${item.id}`), - primaryModel: primary.model ? `${primary.model.providerID}/${primary.model.id}` : null - } + const primary = agents.success + ? agents.data.data + .map((item) => agent.safeParse(item)) + .find((item) => item.success && item.data.mode !== 'subagent' && !item.data.hidden) + : undefined + const primaryAgent = primary?.success ? primary.data.id : undefined + return primaryAgent ? { primaryAgent } : null } export async function probeOpenCodeLaunchModelContext(options: { @@ -100,8 +52,6 @@ export async function probeOpenCodeLaunchModelContext(options: { cwd: string env: NodeJS.ProcessEnv signal?: AbortSignal - expectedPrimaryAgent?: string - expectedPrimaryModel?: string }): Promise { const password = randomBytes(32).toString('base64url') const spawnSpec = createProviderSpawnSpec( @@ -191,28 +141,12 @@ export async function probeOpenCodeLaunchModelContext(options: { await cancelUnreadResponseBody(response) } } - const [models, agents, defaultModel, config] = await Promise.all([ - read('model'), - read('agent'), - read('model/default'), - read('config') - ]) + const agents = await read('agent') context = parseOpenCodeLaunchModelContext({ directory: options.cwd, - models, - agents, - defaultModel, - config + agents }) - if ( - context && - (!options.expectedPrimaryAgent || - context.primaryAgent === options.expectedPrimaryAgent) && - (!options.expectedPrimaryModel || context.primaryModel === options.expectedPrimaryModel) - ) { - break - } - context = null + break } await delay(100, undefined, { signal }) } diff --git a/src/main/opencode/opencode-model-availability.test.ts b/src/main/opencode/opencode-model-availability.test.ts deleted file mode 100644 index 5452b75f811..00000000000 --- a/src/main/opencode/opencode-model-availability.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { runProcess } from '@orca/process-host' -import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' -import { - probeOpenCodeModelAvailability, - resolveOpenCodeDirectModelExecutable -} from './opencode-model-availability' -vi.mock('@orca/process-host', () => ({ runProcess: vi.fn() })) -vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: vi.fn() })) -const options = { - command: '/private/opencode', - model: 'opencode/fledge-alpha-free', - cwd: '/tmp/project', - env: { OPENCODE_CONFIG_DIR: '/private/config', XDG_DATA_HOME: '/private/account' } -} -describe('OpenCode model catalog validation', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(resolveCommandOnLocalPath).mockResolvedValue('/resolved/opencode') - vi.mocked(runProcess).mockResolvedValue({ - code: 0, - timedOut: false, - stdout: 'opencode/fledge-alpha-free\nopencode/big-pickle\n', - stderr: '', - signal: null - }) - }) - it('checks exact catalog membership using the execution scope', async () => { - expect(await probeOpenCodeModelAvailability(options)).toBe(true) - expect(runProcess).toHaveBeenCalledWith( - expect.objectContaining({ - program: '/resolved/opencode', - args: ['models'], - cwd: options.cwd, - env: options.env - }) - ) - }) - it('resolves the default PATH executable before checking model availability', async () => { - expect(await resolveOpenCodeDirectModelExecutable({ ...options, command: 'opencode' })).toBe( - '/resolved/opencode' - ) - expect(resolveCommandOnLocalPath).toHaveBeenCalledWith('opencode', { - env: options.env, - cwd: options.cwd - }) - }) - it('rejects the real invalid selector that falls back to Big Pickle', async () => { - expect(await probeOpenCodeModelAvailability({ ...options, model: 'gpt-5-nano' })).toBe(false) - }) - it('does not accept substrings or verbose metadata', async () => { - expect(await probeOpenCodeModelAvailability({ ...options, model: 'fledge-alpha-free' })).toBe( - false - ) - }) - it('refuses timed out catalog probes even with matching partial output', async () => { - vi.mocked(runProcess).mockResolvedValue({ - code: 0, - timedOut: true, - stdout: options.model, - stderr: '', - signal: null - }) - expect(await probeOpenCodeModelAvailability(options)).toBe(false) - }) - it('refuses a truncated catalog even when its retained head contains the model', async () => { - vi.mocked(runProcess).mockResolvedValue({ - code: 0, - timedOut: false, - outputTruncated: true, - stdout: `${options.model}\n`, - stderr: '', - signal: null - }) - expect(await probeOpenCodeModelAvailability(options)).toBe(false) - }) - it('refuses the unterminated catalog shape captured from OpenCode 2.0.16', async () => { - vi.mocked(runProcess).mockResolvedValue({ - code: 0, - timedOut: false, - outputTruncated: false, - stdout: `${options.model}\npriv`, - stderr: '', - signal: null - }) - expect(await probeOpenCodeModelAvailability(options)).toBe(false) - }) - it.each(['\n', '\r\n'])('accepts a complete catalog with %j line endings', async (ending) => { - vi.mocked(runProcess).mockResolvedValue({ - code: 0, - timedOut: false, - outputTruncated: false, - stdout: `${options.model}${ending}`, - stderr: '', - signal: null - }) - expect(await probeOpenCodeModelAvailability(options)).toBe(true) - }) - it('refuses WSL until its exact guest launch environment is available', async () => { - expect(await probeOpenCodeModelAvailability({ ...options, wsl: { distro: 'Ubuntu' } })).toBe( - false - ) - expect(runProcess).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/opencode/opencode-model-availability.ts b/src/main/opencode/opencode-model-executable.ts similarity index 52% rename from src/main/opencode/opencode-model-availability.ts rename to src/main/opencode/opencode-model-executable.ts index b732a7806bd..c9ac53d4666 100644 --- a/src/main/opencode/opencode-model-availability.ts +++ b/src/main/opencode/opencode-model-executable.ts @@ -1,10 +1,8 @@ import { resolveStartupShell, tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' -import { runProcess } from '@orca/process-host' import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' export async function resolveOpenCodeDirectModelExecutable(options: { command: string | undefined - model: string env: NodeJS.ProcessEnv cwd?: string wsl?: { distro?: string } @@ -29,31 +27,3 @@ export async function resolveOpenCodeDirectModelExecutable(options: { cwd: options.cwd }) } - -export async function probeOpenCodeModelAvailability( - options: Parameters[0] -): Promise { - const executable = await resolveOpenCodeDirectModelExecutable(options) - if (!executable) { - return false - } - try { - const result = await runProcess({ - program: executable, - args: ['models'], - cwd: options.cwd, - env: options.env, - timeoutMs: 10_000, - maxOutputBytes: 1_048_576 - }) - return ( - result.code === 0 && - !result.timedOut && - !result.outputTruncated && - result.stdout.endsWith('\n') && - result.stdout.split(/\r?\n/).some((line) => line === options.model) - ) - } catch { - return false - } -} diff --git a/src/main/opencode/opencode-model-startup-plan.test.ts b/src/main/opencode/opencode-model-startup-plan.test.ts index 26597da77c6..2e3e735e1a3 100644 --- a/src/main/opencode/opencode-model-startup-plan.test.ts +++ b/src/main/opencode/opencode-model-startup-plan.test.ts @@ -2,20 +2,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolveAgentStartupPlanInputs } from '../../shared/agent-startup-plan-inputs' import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' -import { - probeOpenCodeModelAvailability, - resolveOpenCodeDirectModelExecutable -} from './opencode-model-availability' +import { resolveOpenCodeDirectModelExecutable } from './opencode-model-executable' import { probeOpenCodeLaunchModelContext } from './opencode-launch-model-context' import { buildExecutionHostAgentStartupPlan } from './opencode-model-startup-plan' +import { + OPENCODE_MODEL_CONFIG_REFUSAL, + OPENCODE_MODEL_TRANSPORT_REFUSAL, + OPENCODE_MODEL_VERSION_REFUSAL +} from '../../shared/agent-launch-model-preferences' vi.mock('../managed-data-accounts/launch-environment', () => ({ applyManagedDataAccountEnvironment: vi.fn() })) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) -vi.mock('./opencode-model-availability', () => ({ - resolveOpenCodeDirectModelExecutable: vi.fn(), - probeOpenCodeModelAvailability: vi.fn() +vi.mock('./opencode-model-executable', () => ({ + resolveOpenCodeDirectModelExecutable: vi.fn() })) vi.mock('./opencode-launch-model-context', () => ({ probeOpenCodeLaunchModelContext: vi.fn() })) @@ -53,18 +54,7 @@ describe('execution-host OpenCode model startup', () => { pluginApi: 'v2', promptMode: 'prefill' }) - vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) - vi.mocked(probeOpenCodeLaunchModelContext) - .mockResolvedValueOnce({ - primaryAgent: 'reviewer', - availableModels: [preferred], - primaryModel: 'opencode/big-pickle' - }) - .mockResolvedValue({ - primaryAgent: 'reviewer', - availableModels: [preferred], - primaryModel: preferred - }) + vi.mocked(probeOpenCodeLaunchModelContext).mockResolvedValue({ primaryAgent: 'reviewer' }) }) it('refuses unsupported effort preferences before probing or dropping them', async () => { @@ -105,12 +95,13 @@ describe('execution-host OpenCode model startup', () => { } ) - it('uses the selected account environment in both authoritative probes', async () => { + it('uses the selected account environment in its single primary-agent probe', async () => { vi.mocked(applyManagedDataAccountEnvironment).mockImplementation((env) => { env.XDG_DATA_HOME = '/selected/data' env.OPENCODE_DB = 'opencode.db' }) await buildExecutionHostAgentStartupPlan(scope()) + expect(probeOpenCodeLaunchModelContext).toHaveBeenCalledOnce() for (const call of vi.mocked(probeOpenCodeLaunchModelContext).mock.calls) { expect(call[0].env).toMatchObject({ XDG_DATA_HOME: '/selected/data', @@ -120,7 +111,7 @@ describe('execution-host OpenCode model startup', () => { }) it.each(['1.18.30', '1.18.35', '1.19.4'])( - 'preserves the verified legacy launch route %s', + 'launches legacy %s with the requested model flag and no catalog probe', async (version) => { vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ version, @@ -149,28 +140,20 @@ describe('execution-host OpenCode model startup', () => { const options = scope() options.inputs.agentArgs = agentArgs await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ - code: 'capability_unsupported' + code: 'capability_unsupported', + message: OPENCODE_MODEL_TRANSPORT_REFUSAL }) expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() } ) - it('refuses unavailable models and mismatched observed model preferences', async () => { - vi.mocked(probeOpenCodeLaunchModelContext) - .mockReset() - .mockResolvedValue({ primaryAgent: 'reviewer', availableModels: [], primaryModel: null }) - await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ - code: 'capability_unsupported' - }) - vi.mocked(probeOpenCodeLaunchModelContext) - .mockReset() - .mockResolvedValue({ - primaryAgent: 'reviewer', - availableModels: [preferred], - primaryModel: 'opencode/big-pickle' - }) - await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ - code: 'capability_unsupported' + it('keeps the v2 overlay when primary metadata is unavailable', async () => { + vi.mocked(probeOpenCodeLaunchModelContext).mockResolvedValue(null) + const plan = await buildExecutionHostAgentStartupPlan(scope()) + expect(plan?.launchCommand).not.toContain('--model') + expect(JSON.parse(plan?.env?.OPENCODE_CONFIG_CONTENT ?? 'null')).toEqual({ + ...JSON.parse(original), + model: preferred }) }) @@ -187,21 +170,40 @@ describe('execution-host OpenCode model startup', () => { expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() }) - it.each(['1.18.29', '2.0.13', '2.0.14', '2.0.18', '2.1.5-beta.1', '3.0.0'])( - 'refuses unverified version %s before model probing', - async (version) => { - vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ - version, - pluginApi: 'v2', - promptMode: 'prefill' - }) - await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ - code: 'capability_unsupported' - }) - expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() - expect(probeOpenCodeModelAvailability).not.toHaveBeenCalled() - } - ) + it.each([ + '1.18.29', + '1.20.0-beta.1', + '2.0.12', + '2.0.13', + '2.0.14', + '2.0.15', + '2.0.17', + '2.0.18', + '2.1.5-beta.1', + '3.0.0', + null + ])('refuses unverified version %s before model probing', async (version) => { + vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ + version, + pluginApi: 'v2', + promptMode: 'prefill' + }) + await expect(buildExecutionHostAgentStartupPlan(scope())).rejects.toMatchObject({ + code: 'capability_unsupported', + message: OPENCODE_MODEL_VERSION_REFUSAL + }) + expect(probeOpenCodeLaunchModelContext).not.toHaveBeenCalled() + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() + }) + + it('keeps invalid inline configuration guidance separate from a version refusal', async () => { + const options = scope() + options.inputs.agentEnv.OPENCODE_CONFIG_CONTENT = '{' + await expect(buildExecutionHostAgentStartupPlan(options)).rejects.toMatchObject({ + code: 'capability_unsupported', + message: OPENCODE_MODEL_CONFIG_REFUSAL + }) + }) it('resolves the default bare launch command through the execution host', async () => { await buildExecutionHostAgentStartupPlan(scope()) diff --git a/src/main/opencode/opencode-model-startup-plan.ts b/src/main/opencode/opencode-model-startup-plan.ts index c53478f3a36..502c66250ec 100644 --- a/src/main/opencode/opencode-model-startup-plan.ts +++ b/src/main/opencode/opencode-model-startup-plan.ts @@ -18,10 +18,12 @@ import { getTuiAgentLaunchCommand, TUI_AGENT_CONFIG } from '../../shared/tui-age import { applyManagedDataAccountEnvironment } from '../managed-data-accounts/launch-environment' import { OrchestrationError } from '../runtime/orchestration/orchestration-error' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' +import { resolveOpenCodeDirectModelExecutable } from './opencode-model-executable' import { - probeOpenCodeModelAvailability, - resolveOpenCodeDirectModelExecutable -} from './opencode-model-availability' + OPENCODE_MODEL_CONFIG_REFUSAL, + OPENCODE_MODEL_TRANSPORT_REFUSAL, + OPENCODE_MODEL_VERSION_REFUSAL +} from '../../shared/agent-launch-model-preferences' type StartupScope = { inputs: AgentStartupPlanInputs @@ -31,11 +33,36 @@ type StartupScope = { signal?: AbortSignal } -function refuseModel(): never { - throw new OrchestrationError( - 'capability_unsupported', - 'The execution host cannot verify this OpenCode model launch.' - ) +function refuseModel(message = OPENCODE_MODEL_TRANSPORT_REFUSAL): never { + throw new OrchestrationError('capability_unsupported', message) +} + +export async function resolveOpenCodeModelLaunchRoute( + options: Pick +) { + const { inputs } = options + const command = + inputs.cmdOverrides.opencode || + getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, inputs.platform) + const env: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...inputs.agentEnv })) { + if (value !== undefined) { + env[key] = value + } + } + applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) + const capabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: 'opencode', + cwd: options.cwd, + env, + hostIdentity: options.hostIdentity + }).catch(() => null) + const legacy = isVerifiedOpenCodeLegacyModelVersion(capabilities?.version) + if (!legacy && !isVerifiedOpenCodeConfigModelVersion(capabilities?.version)) { + refuseModel(OPENCODE_MODEL_VERSION_REFUSAL) + } + return { command, env, legacy } } export async function prepareOpenCodeModelStartupInputs( @@ -63,72 +90,33 @@ export async function prepareOpenCodeModelStartupInputs( ) { refuseModel() } - const command = - inputs.cmdOverrides.opencode || - getTuiAgentLaunchCommand(TUI_AGENT_CONFIG.opencode, inputs.platform) - const env: Record = {} - for (const [key, value] of Object.entries({ ...process.env, ...inputs.agentEnv })) { - if (value !== undefined) { - env[key] = value - } - } - applyManagedDataAccountEnvironment(env, { launchAgent: 'opencode' }) - const executable = await resolveOpenCodeDirectModelExecutable({ - command, - model, - cwd: options.cwd, - env - }) - if (!executable) { - refuseModel() - } - const capabilities = await probeOpenCodeLaunchCapabilities({ - command, - agent: 'opencode', - cwd: options.cwd, - env, - hostIdentity: options.hostIdentity - }) - if (isVerifiedOpenCodeLegacyModelVersion(capabilities?.version)) { - if (!(await probeOpenCodeModelAvailability({ command, model, cwd: options.cwd, env }))) { - refuseModel() - } + const { command, env, legacy } = await resolveOpenCodeModelLaunchRoute(options) + if (legacy) { return { inputs } } - if (!isVerifiedOpenCodeConfigModelVersion(capabilities?.version) || inputs.agentArgs?.trim()) { + if (inputs.agentArgs?.trim()) { refuseModel() } - const before = await probeOpenCodeLaunchModelContext({ - executable, + const executable = await resolveOpenCodeDirectModelExecutable({ + command, cwd: options.cwd, - env, - signal: options.signal + env }).catch(() => null) - if (!before?.availableModels.includes(model)) { - refuseModel() - } + const context = executable + ? await probeOpenCodeLaunchModelContext({ + executable, + cwd: options.cwd, + env, + signal: options.signal + }).catch(() => null) + : null const configContent = resolveOpenCodeLaunchModelConfig({ configContent: env.OPENCODE_CONFIG_CONTENT, - primaryAgent: before.primaryAgent, + primaryAgent: context?.primaryAgent, model }) if (configContent === null) { - refuseModel() - } - const after = await probeOpenCodeLaunchModelContext({ - executable, - cwd: options.cwd, - env: { ...env, OPENCODE_CONFIG_CONTENT: configContent }, - expectedPrimaryAgent: before.primaryAgent, - expectedPrimaryModel: model, - signal: options.signal - }).catch(() => null) - if ( - after?.primaryAgent !== before.primaryAgent || - after.primaryModel !== model || - !after.availableModels.includes(model) - ) { - refuseModel() + refuseModel(OPENCODE_MODEL_CONFIG_REFUSAL) } return { inputs: { diff --git a/src/main/opencode/opencode-resolved-agents.fixture.json b/src/main/opencode/opencode-resolved-agents.fixture.json new file mode 100644 index 00000000000..fe6381019ad --- /dev/null +++ b/src/main/opencode/opencode-resolved-agents.fixture.json @@ -0,0 +1,108 @@ +[ + { + "name": "stale-default", + "configContent": "{\"model\": \"opencode/big-pickle\", \"default_agent\": \"qa-stale-missing\", \"agents\": {\"reviewer\": {\"mode\": \"primary\", \"model\": \"opencode/big-pickle\", \"system\": \"Review the requested change.\"}}}", + "model": "opencode/mimo-v2.6-flash-free", + "initial": { + "location": { + "directory": "/qa/project" + }, + "data": [] + }, + "settled": { + "location": { + "directory": "/qa/project" + }, + "data": [ + { + "id": "build", + "mode": "primary", + "hidden": false + }, + { + "id": "general", + "mode": "subagent", + "hidden": false + }, + { + "id": "explore", + "mode": "subagent", + "hidden": false + }, + { + "id": "compaction", + "mode": "primary", + "hidden": true + }, + { + "id": "title", + "mode": "primary", + "hidden": true + }, + { + "id": "summary", + "mode": "primary", + "hidden": true + }, + { + "id": "plan", + "mode": "primary", + "hidden": false + }, + { + "id": "reviewer", + "mode": "primary", + "hidden": false + } + ] + } + }, + { + "name": "disabled-build", + "configContent": "{\"model\": \"opencode/big-pickle\", \"agents\": {\"build\": {\"disabled\": true}}}", + "model": "opencode/mimo-v2.6-flash-free", + "initial": { + "location": { + "directory": "/qa/project" + }, + "data": [] + }, + "settled": { + "location": { + "directory": "/qa/project" + }, + "data": [ + { + "id": "plan", + "mode": "primary", + "hidden": false + }, + { + "id": "general", + "mode": "subagent", + "hidden": false + }, + { + "id": "explore", + "mode": "subagent", + "hidden": false + }, + { + "id": "compaction", + "mode": "primary", + "hidden": true + }, + { + "id": "title", + "mode": "primary", + "hidden": true + }, + { + "id": "summary", + "mode": "primary", + "hidden": true + } + ] + } + } +] diff --git a/src/main/runtime/opencode-wsl-folder-launch-host.test.ts b/src/main/runtime/opencode-wsl-folder-launch-host.test.ts index 72861dfba04..40424d9f36b 100644 --- a/src/main/runtime/opencode-wsl-folder-launch-host.test.ts +++ b/src/main/runtime/opencode-wsl-folder-launch-host.test.ts @@ -21,8 +21,7 @@ vi.mock('../opencode/opencode-launch-capabilities', () => ({ promptMode: 'submit' }) })) -vi.mock('../opencode/opencode-model-availability', () => ({ - probeOpenCodeModelAvailability: async () => true, +vi.mock('../opencode/opencode-model-executable', () => ({ resolveOpenCodeDirectModelExecutable: async () => 'opencode' })) @@ -109,6 +108,6 @@ it('refuses an OpenCode model in a WSL-share repo set to Windows host', async () Object.assign(runtime, { resolveTerminalWorkspaceLaunchScope: async () => shareScope }) const request = { clientOperationId, worktree: 'id:s', agent: 'opencode' } as const const picked = runtime.createAgentSession({ ...request, launchPreferences: { model: 'm' } }) - await expect(picked).rejects.toThrow('cannot verify this OpenCode') + await expect(picked).rejects.toThrow('OpenCode cannot apply this model choice') expect(spawned()).toEqual([]) }) diff --git a/src/main/runtime/orchestration-opencode-model-host.test.ts b/src/main/runtime/orchestration-opencode-model-host.test.ts index 6d12025e232..ca8b10093aa 100644 --- a/src/main/runtime/orchestration-opencode-model-host.test.ts +++ b/src/main/runtime/orchestration-opencode-model-host.test.ts @@ -1,17 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' -import { - probeOpenCodeModelAvailability, - resolveOpenCodeDirectModelExecutable -} from '../opencode/opencode-model-availability' +import { resolveOpenCodeDirectModelExecutable } from '../opencode/opencode-model-executable' import { probeOpenCodeLaunchCapabilities } from '../opencode/opencode-launch-capabilities' import { resolveLocalProjectRuntimeForRepo } from '../project-runtime-git-options' +import { OPENCODE_MODEL_VERSION_REFUSAL } from '../../shared/agent-launch-model-preferences' vi.mock('../opencode/opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: vi.fn() })) -vi.mock('../opencode/opencode-model-availability', () => ({ - probeOpenCodeModelAvailability: vi.fn(), +vi.mock('../opencode/opencode-model-executable', () => ({ resolveOpenCodeDirectModelExecutable: vi.fn() })) vi.mock('../managed-data-accounts/launch-environment', () => ({ @@ -49,7 +46,6 @@ function probe( describe('OpenCode worker model execution host', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(true) vi.mocked(resolveOpenCodeDirectModelExecutable).mockResolvedValue('/tmp/private-opencode') vi.mocked(resolveLocalProjectRuntimeForRepo).mockReturnValue(undefined) vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ @@ -58,28 +54,28 @@ describe('OpenCode worker model execution host', () => { promptMode: 'submit' }) }) - it('probes a local folder with its configured command and environment', async () => { + it('checks only the version route for a model in a local folder', async () => { expect(await probe(host())).toBe(true) - expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledWith( - expect.objectContaining({ - command: '/tmp/private-opencode', - cwd: '/tmp/folder', - hostIdentity: 'host-1', - env: expect.objectContaining({ OPENCODE_CONFIG_DIR: '/tmp/private-config' }) - }) - ) + expect(probeOpenCodeLaunchCapabilities).toHaveBeenCalledExactlyOnceWith({ + command: '/tmp/private-opencode', + agent: 'opencode', + cwd: '/tmp/folder', + env: expect.objectContaining({ OPENCODE_CONFIG_DIR: '/tmp/private-config' }), + hostIdentity: 'host-1' + }) + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() }) - it('rejects an unknown selector rather than claiming the fallback model', async () => { - vi.mocked(probeOpenCodeModelAvailability).mockResolvedValue(false) - expect(await probe(host())).toBe(false) - }) - it.each(['v2', 'unknown'] as const)('refuses %s CLI model selection', async (pluginApi) => { + it('refuses an unsafe version before model discovery or worker creation', async () => { vi.mocked(probeOpenCodeLaunchCapabilities).mockResolvedValue({ - version: null, - pluginApi, + version: '2.0.14', + pluginApi: 'v2', promptMode: 'unknown' }) - expect(await probe(host())).toBe(false) + await expect(probe(host())).rejects.toMatchObject({ + code: 'capability_unsupported', + message: OPENCODE_MODEL_VERSION_REFUSAL + }) + expect(resolveOpenCodeDirectModelExecutable).not.toHaveBeenCalled() }) it('never probes the client executable for an SSH workspace', async () => { expect(await probe(host({ path: '/remote/folder', connectionId: 'ssh-1' }))).toBe(false) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index 157cbbad440..588a816f788 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -4,6 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { ORCHESTRATION_METHODS } from './orchestration' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { OPENCODE_MODEL_VERSION_REFUSAL } from '../../../../shared/agent-launch-model-preferences' const STRUCTURED_HANDLE = 'structworker_abc' // A real Orca session ID: a minted worker's preamble names it by this, not its handle. @@ -218,6 +220,28 @@ describe('worker-start honours the settings default', () => { ) }) + it.each([false, true])( + 'keeps OpenCode version refusal before worker effects with native default %s', + async (experimentalNativeChat) => { + const dispatch = vi.spyOn(db, 'createStartingWorkerDispatch') + vi.spyOn(runtime, 'probeOrchestrationOpenCodeModelLaunchSupport').mockRejectedValue( + new OrchestrationError('capability_unsupported', OPENCODE_MODEL_VERSION_REFUSAL) + ) + await expect( + startWorker( + { ...STRUCTURED_DEFAULT, experimentalNativeChat }, + { agent: 'opencode', model: 'opencode/fledge-alpha-free' } + ) + ).rejects.toMatchObject({ + code: 'capability_unsupported', + message: OPENCODE_MODEL_VERSION_REFUSAL + }) + expect(dispatch).not.toHaveBeenCalled() + expect(createExistingWorktreeWorkerTerminal).not.toHaveBeenCalled() + expect(createStructuredWorkerSessionForWorktree).not.toHaveBeenCalled() + } + ) + it('creates a new worktree WITHOUT an agent terminal and gives it a structured session', async () => { // Agent-first new worktrees previously made structured mode unreachable. const created = mockWorktreeCreation() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts index 395ee332be9..5c60e0609ae 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-launch-preferences.test.ts @@ -117,10 +117,10 @@ describe('orchestration worker launch preferences', () => { ).toThrow('does not support effort turbo') }) - it('refuses an opencode model until the execution host verifies legacy TUI support', () => { + it('refuses an OpenCode model on an unsupported execution host', () => { expect(() => resolveWorkerLaunchPreferences({ agent: 'opencode', model: 'meta/muse-spark-1.3' }) - ).toThrow('cannot verify launch-time model selection') + ).toThrow('cannot apply a model choice on this execution host') }) it('explains the existing-worktree requirement even if a new-worktree host is verified', () => { diff --git a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts index dcc15611587..e81d5fe5b94 100644 --- a/src/main/runtime/terminal/wait/terminal-interactive-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-interactive-wait.ts @@ -31,9 +31,8 @@ import { isTuiAgent } from '../../../../shared/tui-agent-config' import { resolveConfiguredWorkerAgent } from '../../orchestration/configured-worker-agent-selector' import { resolveAgentLaunchRuntime } from '../../agent-launch-scope-runtime' import { parseWslUncPath } from '../../../../shared/wsl-paths' - -import { prepareOpenCodeModelStartupInputs } from '../../../opencode/opencode-model-startup-plan' import { resolveAgentStartupPlanInputs } from '../../../../shared/agent-startup-plan-inputs' +import { resolveOpenCodeModelLaunchRoute } from '../../../opencode/opencode-model-startup-plan' import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records' @@ -293,25 +292,26 @@ export abstract class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRunt } catch { return false } - try { - await prepareOpenCodeModelStartupInputs({ - inputs: resolveAgentStartupPlanInputs({ - agent: 'opencode', - settings: store.getSettings(), - platform: launchRuntime.platform, - isRemote: false, - // A yes/no model probe; nothing is typed into a shell. - shell: undefined, - sessionOptions: { model: target.model } - }), - cwd: path, - isWsl: Boolean(parseWslUncPath(path)), - hostIdentity: this.getRuntimeId() - }) - return true - } catch { + if ( + !target.model.trim() || + launchRuntime.platform !== process.platform || + parseWslUncPath(path) + ) { return false } + await resolveOpenCodeModelLaunchRoute({ + inputs: resolveAgentStartupPlanInputs({ + agent: 'opencode', + settings: store.getSettings(), + platform: launchRuntime.platform, + isRemote: false, + shell: undefined, + sessionOptions: { model: target.model } + }), + cwd: path, + hostIdentity: this.getRuntimeId() + }) + return true } validateOrchestrationAgentLauncher(agent: TuiAgent): void { diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 837ef04053e..a81ef5ae899 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "invalidConfig": "OpenCode could not apply this model choice to the launch configuration. Check the OpenCode launch configuration and try again, or omit the model choice.", + "unsupportedLaunch": "OpenCode cannot apply this model choice to this launch. Start a fresh local terminal with supported launch arguments, or omit the model choice.", + "updateRequired": "OpenCode cannot safely start with a chosen model on this version. Update OpenCode to a supported release, or omit the model choice." + } + }, "agentsSidebarIntro": { "migrated": { "action": "Open Agents" diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 137f0a11c7d..c2f7df9bcd1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "OpenCode cannot safely start with a chosen model on this version. Update OpenCode to a supported release, or omit the model choice.", + "invalidConfig": "OpenCode could not apply this model choice to the launch configuration. Check the OpenCode launch configuration and try again, or omit the model choice.", + "unsupportedLaunch": "OpenCode cannot apply this model choice to this launch. Start a fresh local terminal with supported launch arguments, or omit the model choice." + } + }, "csv": { "detectingDelimiter": "Detecting delimiter…", "indexProgress": "Indexing CSV… {{percent}}%", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8ad5438a76f..a1ed70c2b4f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "Esta versión de OpenCode no puede iniciarse de forma segura con el modelo elegido. Actualiza OpenCode a una versión compatible u omite la selección de modelo.", + "invalidConfig": "OpenCode no pudo aplicar este modelo a la configuración de inicio. Revisa la configuración de inicio de OpenCode y vuelve a intentarlo, u omite la selección de modelo.", + "unsupportedLaunch": "OpenCode no puede aplicar este modelo a este inicio. Abre un terminal local nuevo con argumentos de inicio compatibles u omite la selección de modelo." + } + }, "app": { "saving": { "delayedTitle": "El almacenamiento del perfil está tardando más de lo habitual", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index cfe29e3714e..c003c2fe729 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "Cette version d’OpenCode ne peut pas démarrer de façon sûre avec le modèle choisi. Mettez OpenCode à jour vers une version compatible ou ne précisez pas de modèle.", + "invalidConfig": "OpenCode n’a pas pu appliquer ce modèle à la configuration de lancement. Vérifiez la configuration de lancement d’OpenCode et réessayez, ou ne précisez pas de modèle.", + "unsupportedLaunch": "OpenCode ne peut pas appliquer ce modèle à ce lancement. Ouvrez un nouveau terminal local avec des arguments de lancement compatibles ou ne précisez pas de modèle." + } + }, "app": { "saving": { "delayedTitle": "Le stockage du profil prend plus de temps que d’habitude", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e120372dedc..7f863586919 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "このバージョンの OpenCode では、選択したモデルで安全に起動できません。OpenCode を対応バージョンに更新するか、モデルの指定を省略してください。", + "invalidConfig": "OpenCode は選択したモデルを起動設定に適用できませんでした。OpenCode の起動設定を確認して再試行するか、モデルの指定を省略してください。", + "unsupportedLaunch": "OpenCode はこの起動に選択したモデルを適用できません。対応する起動引数で新しいローカルターミナルを開くか、モデルの指定を省略してください。" + } + }, "app": { "saving": { "delayedTitle": "プロファイルのストレージ処理に通常より時間がかかっています", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 03a4d8e9ad4..b593ce80d24 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "이 버전의 OpenCode는 선택한 모델로 안전하게 시작할 수 없습니다. OpenCode를 지원되는 버전으로 업데이트하거나 모델 선택을 생략하세요.", + "invalidConfig": "OpenCode가 선택한 모델을 시작 구성에 적용하지 못했습니다. OpenCode 시작 구성을 확인하고 다시 시도하거나 모델 선택을 생략하세요.", + "unsupportedLaunch": "OpenCode가 이번 시작에 선택한 모델을 적용할 수 없습니다. 지원되는 시작 인수를 사용하여 새 로컬 터미널을 열거나 모델 선택을 생략하세요." + } + }, "app": { "saving": { "delayedTitle": "프로필 저장소 작업에 평소보다 오래 걸리고 있습니다", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 02a949d89d2..6761ae22484 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "Esta versão do OpenCode não pode iniciar com segurança com o modelo escolhido. Atualize o OpenCode para uma versão compatível ou omita a escolha do modelo.", + "invalidConfig": "O OpenCode não conseguiu aplicar este modelo à configuração de inicialização. Verifique a configuração de inicialização do OpenCode e tente novamente, ou omita a escolha do modelo.", + "unsupportedLaunch": "O OpenCode não pode aplicar este modelo a esta inicialização. Abra um novo terminal local com argumentos de inicialização compatíveis ou omita a escolha do modelo." + } + }, "common": { "retry": "Tentar novamente", "cancel": "Cancelar" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 55d04efcdb2..44b5eba924c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1,4 +1,11 @@ { + "agentLaunch": { + "opencode": { + "updateRequired": "此版本的 OpenCode 无法使用所选模型安全启动。请将 OpenCode 更新到受支持的版本,或不指定模型。", + "invalidConfig": "OpenCode 无法将所选模型应用到启动配置。请检查 OpenCode 启动配置后重试,或不指定模型。", + "unsupportedLaunch": "OpenCode 无法在此次启动中应用所选模型。请使用受支持的启动参数打开新的本地终端,或不指定模型。" + } + }, "app": { "saving": { "delayedTitle": "配置存储操作耗时比平时更长", diff --git a/src/renderer/src/lib/ipc-error.test.ts b/src/renderer/src/lib/ipc-error.test.ts index 5c0fc1d30ac..ba831336d1a 100644 --- a/src/renderer/src/lib/ipc-error.test.ts +++ b/src/renderer/src/lib/ipc-error.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it } from 'vitest' +import { i18n } from '../i18n/i18n' +import es from '../i18n/locales/es.json' +import { + OPENCODE_MODEL_CONFIG_REFUSAL, + OPENCODE_MODEL_TRANSPORT_REFUSAL, + OPENCODE_MODEL_VERSION_REFUSAL +} from '../../../shared/agent-launch-model-preferences' import { compactIpcErrorMessage, extractIpcErrorMessage, @@ -7,6 +14,30 @@ import { } from './ipc-error' describe('readIpcErrorMessage', () => { + it('localizes version, configuration and transport refusals without changing other errors', async () => { + const language = i18n.language + i18n.addResourceBundle('es', 'translation', es) + await i18n.changeLanguage('es') + try { + for (const [message, expected] of [ + [OPENCODE_MODEL_VERSION_REFUSAL, es.agentLaunch.opencode.updateRequired], + [OPENCODE_MODEL_CONFIG_REFUSAL, es.agentLaunch.opencode.invalidConfig], + [OPENCODE_MODEL_TRANSPORT_REFUSAL, es.agentLaunch.opencode.unsupportedLaunch] + ]) { + expect( + readIpcErrorMessage( + new Error(`Error invoking remote method 'pty:create': OrchestrationError: ${message}`) + ) + ).toBe(expected) + } + expect(readIpcErrorMessage(new Error('OpenCode: another failure'))).toBe( + 'OpenCode: another failure' + ) + } finally { + await i18n.changeLanguage(language) + } + }) + it('strips the Electron invoke wrapper Electron adds to a rejected handler', () => { expect( readIpcErrorMessage( diff --git a/src/renderer/src/lib/ipc-error.ts b/src/renderer/src/lib/ipc-error.ts index 3908b9e7447..7143282331b 100644 --- a/src/renderer/src/lib/ipc-error.ts +++ b/src/renderer/src/lib/ipc-error.ts @@ -1,3 +1,10 @@ +import { translate } from '../i18n/i18n' +import { + OPENCODE_MODEL_CONFIG_REFUSAL, + OPENCODE_MODEL_TRANSPORT_REFUSAL, + OPENCODE_MODEL_VERSION_REFUSAL +} from '../../../shared/agent-launch-model-preferences' + // Unanchored so caller-owned context around an Electron wrapper survives. const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/ const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/ @@ -8,7 +15,17 @@ function unwrapIpcErrorMessage(message: string): string | undefined { const wrapped = IPC_INVOKE_PREFIX.test(message) || IPC_HANDLER_PREFIX.test(message) const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim() // Strip class names only from unwrapped main errors; preserve local TypeError identity. - return (wrapped ? detail.replace(ERROR_CLASS_PREFIX, '').trim() : detail) || undefined + const unwrapped = wrapped ? detail.replace(ERROR_CLASS_PREFIX, '').trim() : detail + switch (unwrapped) { + case OPENCODE_MODEL_VERSION_REFUSAL: + return translate('agentLaunch.opencode.updateRequired', OPENCODE_MODEL_VERSION_REFUSAL) + case OPENCODE_MODEL_CONFIG_REFUSAL: + return translate('agentLaunch.opencode.invalidConfig', OPENCODE_MODEL_CONFIG_REFUSAL) + case OPENCODE_MODEL_TRANSPORT_REFUSAL: + return translate('agentLaunch.opencode.unsupportedLaunch', OPENCODE_MODEL_TRANSPORT_REFUSAL) + default: + return unwrapped || undefined + } } export function compactIpcErrorMessage(message: string): string | undefined { diff --git a/src/shared/agent-launch-model-preferences.ts b/src/shared/agent-launch-model-preferences.ts index ea57f9994c0..9bf236bbbba 100644 --- a/src/shared/agent-launch-model-preferences.ts +++ b/src/shared/agent-launch-model-preferences.ts @@ -8,6 +8,13 @@ import { } from './agent-session-option-launch' import type { TuiAgent } from './tui-agent' +export const OPENCODE_MODEL_VERSION_REFUSAL = + 'OpenCode cannot safely start with a chosen model on this version. Update OpenCode to a supported release, or omit the model choice.' +export const OPENCODE_MODEL_TRANSPORT_REFUSAL = + 'OpenCode cannot apply this model choice to this launch. Start a fresh local terminal with supported launch arguments, or omit the model choice.' +export const OPENCODE_MODEL_CONFIG_REFUSAL = + 'OpenCode could not apply this model choice to the launch configuration. Check the OpenCode launch configuration and try again, or omit the model choice.' + export type AgentLaunchModelPreferenceRefusal = { code: 'invalid_argument' | 'capability_unsupported' message: string @@ -19,7 +26,7 @@ export type AgentLaunchModelPreferenceCheck = export function checkAgentLaunchModelPreferences(args: { agent: TuiAgent - /** OpenCode needs the execution host to verify its CLI before it can start on a chosen model. */ + /** OpenCode model choices need a fresh terminal on a supported execution host. */ openCodeModelLaunchSupported?: boolean createsWorktree?: boolean model?: string @@ -40,7 +47,7 @@ export function checkAgentLaunchModelPreferences(args: { if (args.agent === 'opencode' && args.openCodeModelLaunchSupported !== true) { return refuse( 'capability_unsupported', - 'This OpenCode TUI cannot verify launch-time model selection. Omit --model or use a supported OpenCode CLI.' + 'OpenCode cannot apply a model choice on this execution host. Use a fresh local terminal in an existing workspace, or omit --model.' ) }