diff --git a/src/main/agent-hooks/wsl-guest-plugin-install.test.ts b/src/main/agent-hooks/wsl-guest-plugin-install.test.ts index 11ec2cae20b..c1a22cf1e96 100644 --- a/src/main/agent-hooks/wsl-guest-plugin-install.test.ts +++ b/src/main/agent-hooks/wsl-guest-plugin-install.test.ts @@ -26,6 +26,32 @@ describe('requestGuestOpenCodeOverlayDir', () => { }) }) + // Why: each OpenCode major gets its own guest overlay. Collapsing them here would + // point an OpenCode 2 pane at the v1 overlay, whose plugin gates itself off. + it('carries the per-major guest overlay dirs independently', async () => { + const { mux } = fakeMux(async () => ({ + overlayDirs: { + opencode: '/home/jin/.orca-relay/opencode-overlays/x', + opencode2: '/home/jin/.orca-relay/opencode2-overlays/y' + } + })) + await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({ + kind: 'dir', + dir: '/home/jin/.orca-relay/opencode-overlays/x', + dir2: '/home/jin/.orca-relay/opencode2-overlays/y' + }) + }) + + it('reports an OpenCode 2 overlay even when the v1 overlay is missing', async () => { + const { mux } = fakeMux(async () => ({ + overlayDirs: { opencode2: '/home/jin/.orca-relay/opencode2-overlays/y' } + })) + await expect(requestGuestOpenCodeOverlayDir(mux, deps(), 'Ubuntu')).resolves.toEqual({ + kind: 'dir', + dir2: '/home/jin/.orca-relay/opencode2-overlays/y' + }) + }) + it("reports 'none' when the guest answered but materialization produced no dir", async () => { // Why: distinct from 'unavailable' — the caller must CLEAR a previously recorded // dir here, since a rebuild that failed after wiping leaves it plugin-less. diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 1170c8425ca..f73f11e7f93 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -330,6 +330,42 @@ describe('registerPtyHandlers', () => { spy.mockRestore() } }) + // Why: the guest materializes one overlay per OpenCode major, each holding only its + // own plugin file. Asking for the wrong one hands the guest the other variant's + // plugin, whose agent gate then registers no hooks at all. + const guestOverlayCases: { + launchAgent?: TuiAgent + expectedAgent: 'opencode' | 'opencode2' + }[] = [ + { expectedAgent: 'opencode' }, + { launchAgent: 'opencode2', expectedAgent: 'opencode2' } + ] + it.each(guestOverlayCases)( + 'selects the $expectedAgent guest overlay for a WSL spawn', + async ({ launchAgent, expectedAgent }) => { + const guestDirs = { + opencode: '/home/jin/.orca-relay/opencode-overlays/abc', + opencode2: '/home/jin/.orca-relay/opencode2-overlays/def' + } + const spy = vi + .spyOn(wslHookRelayManager, 'getOpenCodeOverlayDir') + .mockImplementation((_distro, agent = 'opencode') => guestDirs[agent]) + try { + await withWin32Platform(async () => { + const env = await daemonSpawnAndGetEnv({}, undefined, undefined, undefined, { + shellOverride: 'wsl.exe', + ...(launchAgent ? { launchAgent } : {}) + }) + expect(spy.mock.calls.map(([, agent]) => agent)).toEqual([expectedAgent]) + expect(env.ORCA_OPENCODE_AGENT).toBe(expectedAgent) + expect(env.OPENCODE_CONFIG_DIR).toBe(guestDirs[expectedAgent]) + expect(env.ORCA_OPENCODE_CONFIG_DIR).toBe(guestDirs[expectedAgent]) + }) + } finally { + spy.mockRestore() + } + } + ) it('strips the daemon-inherited Orca-owned CODEX_HOME for real-home routing', async () => { const spawnOptions = await daemonSpawnAndGetOptions( {}, diff --git a/src/main/ipc/pty-ipc-mock-registry.ts b/src/main/ipc/pty-ipc-mock-registry.ts index 11afd2df60a..71b77621b75 100644 --- a/src/main/ipc/pty-ipc-mock-registry.ts +++ b/src/main/ipc/pty-ipc-mock-registry.ts @@ -23,6 +23,7 @@ export const getPathMock: Mock = vi.fn() export const loginPreflightExecFileMock: Mock = vi.fn() export const spawnMock: Mock = vi.fn() export const openCodeBuildPtyEnvMock: Mock = vi.fn() +export const openCode2BuildPtyEnvMock: Mock = vi.fn() export const mimoCodeBuildPtyEnvMock: Mock = vi.fn() export const isPwshAvailableMock: Mock = vi.fn() export const wslUncDirectoryExistsAsyncMock: Mock = vi.fn() @@ -108,6 +109,12 @@ export const openCodeHookServiceModuleMock = () => ({ openCodeHookService: { buildPtyEnv: openCodeBuildPtyEnvMock, clearPty: openCodeClearPtyMock + }, + // Separate mock per variant: assembly.ts picks the service by variant, and a shared + // mock would hide a regression that hands an OpenCode 2 pane the v1 plugin. + openCode2HookService: { + buildPtyEnv: openCode2BuildPtyEnvMock, + clearPty: openCodeClearPtyMock } }) diff --git a/src/main/ipc/pty-ipc-suite-environment.ts b/src/main/ipc/pty-ipc-suite-environment.ts index d372ecb734b..c7256a57f3e 100644 --- a/src/main/ipc/pty-ipc-suite-environment.ts +++ b/src/main/ipc/pty-ipc-suite-environment.ts @@ -22,6 +22,7 @@ import { loginPreflightExecFileMock, spawnMock, openCodeBuildPtyEnvMock, + openCode2BuildPtyEnvMock, mimoCodeBuildPtyEnvMock, openCodeClearPtyMock, buildAgentHookEnvMock, @@ -149,6 +150,7 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { loginPreflightExecFileMock.mockReset() spawnMock.mockReset() openCodeBuildPtyEnvMock.mockReset() + openCode2BuildPtyEnvMock.mockReset() mimoCodeBuildPtyEnvMock.mockReset() openCodeClearPtyMock.mockReset() buildAgentHookEnvMock.mockReset() @@ -218,6 +220,14 @@ export function createPtyIpcSuiteEnvironment(): PtyIpcSuiteEnvironment { ? '/tmp/orca-opencode-overlay' : '/tmp/orca-opencode-config' })) + openCode2BuildPtyEnvMock.mockImplementation((_ptyId: string, existingConfigDir?: string) => ({ + ORCA_OPENCODE_HOOK_PORT: '4567', + ORCA_OPENCODE_HOOK_TOKEN: 'opencode2-token', + ORCA_OPENCODE_PTY_ID: 'test-pty', + OPENCODE_CONFIG_DIR: existingConfigDir + ? '/tmp/orca-opencode2-overlay' + : '/tmp/orca-opencode2-config' + })) mimoCodeBuildPtyEnvMock.mockImplementation((_ptyId: string, existingHome?: string) => ({ MIMOCODE_HOME: existingHome ? '/tmp/orca-mimocode-overlay' : '/tmp/orca-mimocode-shared' })) diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 6b3ca68ae5c..668a879f76a 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -52,6 +52,10 @@ describe('OpenCode status plugin module contract', () => { for (const key of ENV_KEYS) { savedEnv[key] = process.env[key] } + // Why: the generated plugin self-disables when this names a different major, + // so an inherited value from the developer's own Orca pane would leave + // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' process.env.ORCA_AGENT_HOOK_TOKEN = 'test-token' diff --git a/src/relay/plugin-overlay.test.ts b/src/relay/plugin-overlay.test.ts index c7c76cb08fb..14feb631876 100644 --- a/src/relay/plugin-overlay.test.ts +++ b/src/relay/plugin-overlay.test.ts @@ -95,6 +95,28 @@ describe('PluginOverlayManager', () => { ) }) + // Why: the remote/guest config root keeps whichever Orca plugin files earlier + // launches installed. Mirroring the other major's file into this overlay would + // hand the agent a plugin whose variant gate registers nothing. + it.each([ + { agent: 'opencode', stale: 'orca-opencode2-status.js', own: 'orca-opencode-status.js' }, + { agent: 'opencode2', stale: 'orca-opencode-status.js', own: 'orca-opencode2-status.js' } + ] as const)( + "keeps the other major's stale plugin out of the $agent overlay", + ({ agent, stale, own }) => { + const userConfigDir = join(homeDir, '.config', 'opencode') + mkdirSync(join(userConfigDir, 'plugins'), { recursive: true }) + writeFileSync(join(userConfigDir, 'plugins', stale), 'stale other-major plugin') + writeFileSync(join(userConfigDir, 'plugins', 'user-plugin.js'), 'user plugin') + + manager.setSources({ opencodePluginSource: 'v1', opencode2PluginSource: 'v2' }) + const dir = manager.materializeOpenCode('tab-1:0', userConfigDir, agent) + + expect(dir).not.toBeNull() + expect(readdirSync(join(dir!, 'plugins')).sort()).toEqual([own, 'user-plugin.js'].sort()) + } + ) + it('does not override a missing preexisting OpenCode config dir', () => { manager.setSources({ opencodePluginSource: 'orca plugin' })