diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 854882d287f..21ec4ee0936 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -91,32 +91,40 @@ describe('execution-host OpenCode launch preparation', () => { expect(finalEnv.OPENCODE_CONFIG_DIR).toBe('/private/owned-overlay') expect(finalEnv.ORCA_OPENCODE_SOURCE_CONFIG_DIR).toBe('/private/real-source') }) - it('removes only the automatic verified v2 prompt argument, retaining explicit run and manual flags', async () => { - probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) - const env = { - ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', - [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), - [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', - [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + it.each(['2.0.12', '2.0.16'])( + 'removes only the automatic %s prompt argument, retaining explicit run and manual flags', + async (version) => { + probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) + const env = { + ORCA_AGENT_LAUNCH_TOKEN: 'admitted-launch', + [OPENCODE_STARTUP_PROMPT_SHA256_ENV]: createHash('sha256').update('task').digest('hex'), + [OPENCODE_STARTUP_PROMPT_BODY_ENV]: 'task', + [OPENCODE_STARTUP_PROMPT_SHELL_ENV]: 'posix' + } + const options = { env, envToDelete: [], isFreshLaunch: true } + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + command: "opencode --standalone --prompt 'task'" + }) + ).command + ).toBe('opencode --standalone') + expect( + (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) + .command + ).toBe("opencode run --prompt 'task'") + expect( + ( + await prepareOpenCodePtyLaunch({ + ...options, + env: {}, + command: "opencode --prompt 'task'" + }) + ).command + ).toBe("opencode --prompt 'task'") } - const options = { env, envToDelete: [], isFreshLaunch: true } - expect( - ( - await prepareOpenCodePtyLaunch({ - ...options, - command: "opencode --standalone --prompt 'task'" - }) - ).command - ).toBe('opencode --standalone') - expect( - (await prepareOpenCodePtyLaunch({ ...options, command: "opencode run --prompt 'task'" })) - .command - ).toBe("opencode run --prompt 'task'") - expect( - (await prepareOpenCodePtyLaunch({ ...options, env: {}, command: "opencode --prompt 'task'" })) - .command - ).toBe("opencode --prompt 'task'") - }) + ) it.each(['inherited', 'explicit', 'deleted'] as const)( 'passes the %s config environment used by the execution-host version probe to the prompt installer', async (selection) => { @@ -178,9 +186,16 @@ describe('execution-host OpenCode launch preparation', () => { expect(result.env).not.toHaveProperty('ORCA_OPENCODE_STARTUP_PROMPT_NONCE') } ) - it.each(['1.1.23', '2.0.16', '2.0.17', 'unknown'])( - 'gates native intent against the executing %s capability', - async (version) => { + it.each([ + { version: '1.1.23', nativeIntent: false }, + { version: '2.0.12', nativeIntent: true }, + { version: '2.0.16', nativeIntent: true }, + { version: '2.0.17', nativeIntent: false }, + { version: '2.0.21', nativeIntent: false }, + { version: 'unknown', nativeIntent: false } + ])( + 'gates native intent against the executing $version capability', + async ({ version, nativeIntent }) => { probe.mockResolvedValue(getOpenCodeCliCapabilities(version)) const envToDelete: string[] = [] const fingerprint = createHash('sha256').update('task').digest('hex') @@ -195,10 +210,8 @@ describe('execution-host OpenCode launch preparation', () => { envToDelete, isFreshLaunch: true }) - expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe( - version === '2.0.16' ? fingerprint : undefined - ) - expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(version !== '2.0.16') + expect(env?.[OPENCODE_STARTUP_PROMPT_SHA256_ENV]).toBe(nativeIntent ? fingerprint : undefined) + expect(envToDelete.includes(OPENCODE_STARTUP_PROMPT_SHA256_ENV)).toBe(!nativeIntent) } ) @@ -266,7 +279,7 @@ describe('execution-host OpenCode launch preparation', () => { expect(finalEnv).not.toHaveProperty('KEEP_DELETED') }) - it.each(['1.1.23', '2.0.16'])( + it.each(['1.1.23', '2.0.12', '2.0.16'])( 'selects the probed %s plugin for the execution host', async (version) => { const capabilities = getOpenCodeCliCapabilities(version) diff --git a/src/main/opencode/opencode-startup-prompt-source.test.ts b/src/main/opencode/opencode-startup-prompt-source.test.ts index 2e505ee2a8f..ec241a35a6b 100644 --- a/src/main/opencode/opencode-startup-prompt-source.test.ts +++ b/src/main/opencode/opencode-startup-prompt-source.test.ts @@ -42,7 +42,7 @@ class Editor extends EventEmitter { type FixtureLocation = { directory: string; workspaceID?: string } -function fixture() { +function fixture(version = '2.0.16') { const editor = new Editor() const input = new EventEmitter() const memory = { settled: false, expiresAt: Date.now() + 20000 } @@ -52,7 +52,7 @@ function fixture() { const sync = vi.fn(async (_location: FixtureLocation) => {}) const dispatch = vi.fn(() => editor.replace('')) const ctx = { - app: { version: '2.0.16' }, + app: { version }, renderer: { keyInput: input, currentFocusedEditor: editor }, storage: { memory: () => [memory, (mutate: (draft: typeof memory) => void) => mutate(memory)] }, keymap: { dispatch }, @@ -90,6 +90,41 @@ afterEach(() => { }) describe('installed-version native prompt intent plugin', () => { + it.each(['2.0.12', '2.0.16'])( + 'hydrates the source-reviewed %s location and delivers the same intent only once', + async (version) => { + const f = fixture(version) + let hydrate = () => {} + f.sync.mockImplementation( + () => + new Promise((resolve) => { + hydrate = resolve + }) + ) + const insert = vi.spyOn(f.editor, 'insertText') + const dispose = await setup(f.ctx) + try { + await vi.advanceTimersByTimeAsync(500) + expect(f.sync).toHaveBeenCalledExactlyOnceWith(f.ctx.location) + expect(claim).not.toHaveBeenCalled() + expect(insert).not.toHaveBeenCalled() + hydrate() + await vi.advanceTimersByTimeAsync(1000) + await vi.waitFor(() => expect(claim).toHaveBeenCalledTimes(1)) + expect(insert).toHaveBeenCalledExactlyOnceWith(prompt) + expect(f.dispatch).toHaveBeenCalledExactlyOnceWith('prompt.submit') + expect(f.memory.settled).toBe(true) + const reloadDispose = await setup(f.ctx) + await vi.advanceTimersByTimeAsync(500) + expect(claim).toHaveBeenCalledTimes(1) + expect(f.dispatch).toHaveBeenCalledTimes(1) + await reloadDispose() + } finally { + await dispose() + } + } + ) + it('waits for the current home location after the startup directory changes', async () => { const f = fixture() let location: FixtureLocation = { directory: '/private/home/private-folder' } diff --git a/src/main/opencode/opencode-startup-prompt-source.ts b/src/main/opencode/opencode-startup-prompt-source.ts index 4bb37861102..94e08869d6d 100644 --- a/src/main/opencode/opencode-startup-prompt-source.ts +++ b/src/main/opencode/opencode-startup-prompt-source.ts @@ -1,3 +1,4 @@ +import { OPENCODE_NATIVE_PROMPT_VERSIONS } from '../../shared/opencode-cli-version' import { cancelUnreadResponseBody } from '../lib/unread-response-body' import { parseAgentHookEndpointFile } from '../../shared/agent-hook-endpoint-file' import { @@ -40,7 +41,7 @@ async function submitStartupPrompt(ctx) { const nonce = process.env.${OPENCODE_STARTUP_PROMPT_NONCE_ENV}; const endpoint = process.env.${OPENCODE_STARTUP_PROMPT_ENDPOINT_ENV}; const prompt = process.env.${OPENCODE_STARTUP_PROMPT_BODY_ENV}; - if (ctx?.app?.version !== "2.0.16" || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; + if (!${JSON.stringify(OPENCODE_NATIVE_PROMPT_VERSIONS)}.includes(ctx?.app?.version) || !/^[a-f0-9]{64}$/.test(digest || "") || !nonce || !endpoint || !prompt) return noop; const input = ctx.renderer?.keyInput; if (typeof ctx.storage?.memory !== "function" || typeof input?.on !== "function" || typeof input?.off !== "function" || typeof ctx.keymap?.dispatch !== "function" || diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts index cc9c5987f2e..7f63d62e482 100644 --- a/src/shared/opencode-cli-version.test.ts +++ b/src/shared/opencode-cli-version.test.ts @@ -1,9 +1,12 @@ import { expect, it } from 'vitest' import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' -it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { - expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) -}) +it.each(['1.1.23', 'opencode v2.0.12', 'opencode v2.0.16', '2.0.0-beta.1+build'])( + 'parses CLI output %s', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) + } +) it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( 'does not mistake other output for a version', (output) => { @@ -21,6 +24,11 @@ it('keeps future versions unknown instead of assuming a plugin loader or prompt pluginApi: 'v1', promptMode: 'submit' }) + expect(getOpenCodeCliCapabilities('opencode v2.0.12')).toEqual({ + version: '2.0.12', + pluginApi: 'v2', + promptMode: 'prefill' + }) expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ version: '2.0.16', pluginApi: 'v2', @@ -29,6 +37,7 @@ it('keeps future versions unknown instead of assuming a plugin loader or prompt }) it('does not opt unverified v2 builds into an extra prompt submission', () => { - expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') - expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') + for (const version of ['2.0.13', '2.0.15', '2.0.17', '2.0.21', '2.0.12-beta', '2.0.16-beta']) { + expect(getOpenCodeCliCapabilities(version).promptMode).toBe('unknown') + } }) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts index 2aa71b2c30f..13033bce78c 100644 --- a/src/shared/opencode-cli-version.ts +++ b/src/shared/opencode-cli-version.ts @@ -1,5 +1,7 @@ import { isValidAppVersion } from './app-version' +export const OPENCODE_NATIVE_PROMPT_VERSIONS = ['2.0.12', '2.0.16'] as const + export type OpenCodeCliCapabilities = { version: string | null pluginApi: 'v1' | 'v2' | 'unknown' @@ -21,6 +23,11 @@ export function getOpenCodeCliCapabilities( return { version, pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', - promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' + promptMode: + major === '1' + ? 'submit' + : OPENCODE_NATIVE_PROMPT_VERSIONS.some((supported) => supported === version) + ? 'prefill' + : 'unknown' } }