diff --git a/src/chat/opencodeBridge.test.ts b/src/chat/opencodeBridge.test.ts index 41e547d..080e12d 100644 --- a/src/chat/opencodeBridge.test.ts +++ b/src/chat/opencodeBridge.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import * as childProcess from "child_process"; import { __resetMocks, @@ -53,7 +53,11 @@ function createMockDevcontainerManager(opts: { function createMockProcess() { const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); - const stdin = new Writable({ write(_chunk, _enc, cb) { cb(); } }); + const stdin = new Writable({ + write(_chunk, _enc, cb) { + cb(); + }, + }); const proc = { stdout, stderr, @@ -66,14 +70,11 @@ function createMockProcess() { } /** - * Helper: start the bridge and advance fake timers so `waitForReady` - * resolves (the 5 s readiness timeout). + * Helper: flush Node.js stream/readline async processing. + * Stream `data` and readline `line` events fire asynchronously after push(). */ -async function startBridge(bridge: OpenCodeBridge): Promise { - const p = bridge.start(); - // Advance past the SPAWN_READY_TIMEOUT_MS (5 000 ms). - await vi.advanceTimersByTimeAsync(5_000); - await p; +function flushStreams(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)); } let bridge: OpenCodeBridge; @@ -81,8 +82,6 @@ let mockManager: ReturnType; let mockProcess: ReturnType; beforeEach(() => { - vi.useFakeTimers(); - __resetMocks(); __setWorkspaceFolders([{ uri: Uri.file("/home/user/project") }]); @@ -98,10 +97,6 @@ beforeEach(() => { bridge = new OpenCodeBridge(mockManager as any); }); -afterEach(() => { - vi.useRealTimers(); -}); - // --------------------------------------------------------------------------- // Initial state // --------------------------------------------------------------------------- @@ -117,42 +112,33 @@ describe("initial state", () => { }); // --------------------------------------------------------------------------- -// start — local-with-remote-exec +// start — prepares the bridge without spawning a process // --------------------------------------------------------------------------- describe("start — local-with-remote-exec", () => { - it("spawns opencode process with --format json", async () => { - await startBridge(bridge); + it("transitions to idle state (no process spawned)", async () => { + await bridge.start(); - expect(mockSpawn).toHaveBeenCalledOnce(); - const [cmd, args] = mockSpawn.mock.calls[0]; - expect(cmd).toBe("opencode"); - expect(args).toContain("--format"); - expect(args).toContain("json"); + // start() only prepares env / shell wrapper — no process yet. + expect(mockSpawn).not.toHaveBeenCalled(); + expect(bridge.state).toBe("idle"); + expect(bridge.isRunning()).toBe(true); }); - it("sets SHELL env to shell wrapper path", async () => { - await startBridge(bridge); - - const opts = mockSpawn.mock.calls[0][2]; - expect(opts.env.SHELL).toContain("shell-wrapper"); - }); + it("sets up SHELL env with shell wrapper", async () => { + await bridge.start(); - it("sets OPENCODE_DEVCONTAINER env vars", async () => { - await startBridge(bridge); + // Now trigger a prompt to verify the env is passed. + bridge.sendPrompt("hello"); + expect(mockSpawn).toHaveBeenCalledOnce(); const opts = mockSpawn.mock.calls[0][2]; + expect(opts.env.SHELL).toContain("shell-wrapper"); expect(opts.env.OPENCODE_DEVCONTAINER).toBe("1"); expect(opts.env.OPENCODE_DEVCONTAINER_ID).toBe("abc123def456"); expect(opts.env.OPENCODE_WORKSPACE_FOLDER).toBe("/workspaces/project"); }); - it("transitions to idle state after spawn", async () => { - await startBridge(bridge); - expect(bridge.state).toBe("idle"); - expect(bridge.isRunning()).toBe(true); - }); - it("fires error event when container info is unavailable", async () => { mockManager = createMockDevcontainerManager({ state: "running", @@ -164,7 +150,6 @@ describe("start — local-with-remote-exec", () => { const listener = vi.fn(); bridge.onEvent(listener); - // No process is spawned so waitForReady is skipped — resolves immediately. await bridge.start(); expect(listener).toHaveBeenCalledWith( @@ -174,31 +159,15 @@ describe("start — local-with-remote-exec", () => { }) ); expect(bridge.state).toBe("error"); + expect(bridge.isRunning()).toBe(false); }); - it("does not spawn a second process if already running", async () => { - await startBridge(bridge); - await startBridge(bridge); - - expect(mockSpawn).toHaveBeenCalledOnce(); - }); - - it("resolves start() early when process errors during startup", async () => { - const p = bridge.start(); - - // Simulate process error event firing before the readiness timeout. - const errorCall = mockProcess.on.mock.calls.find( - ([event]: [string]) => event === "error" - ); - expect(errorCall).toBeDefined(); - const errorHandler = errorCall![1] as (err: Error) => void; - errorHandler(new Error("spawn ENOENT")); - - // Advance a small amount — should resolve immediately via state listener. - await vi.advanceTimersByTimeAsync(10); - await p; + it("is idempotent when already idle", async () => { + await bridge.start(); + await bridge.start(); - expect(bridge.state).toBe("error"); + // Should not error or re-prepare. + expect(bridge.state).toBe("idle"); }); }); @@ -207,35 +176,15 @@ describe("start — local-with-remote-exec", () => { // --------------------------------------------------------------------------- describe("start — in-container", () => { - it("spawns docker exec with -i flag and the container ID", async () => { - __setMockConfig({ - "opencode-devcontainer.executionMode": "in-container", - }); - - await startBridge(bridge); - - expect(mockSpawn).toHaveBeenCalledOnce(); - const [cmd, args] = mockSpawn.mock.calls[0]; - expect(cmd).toBe("docker"); - expect(args).toContain("exec"); - expect(args).toContain("-i"); - expect(args).toContain("abc123def456"); - expect(args).toContain("opencode"); - }); - - it("places -i flag before other exec arguments", async () => { + it("transitions to idle without spawning", async () => { __setMockConfig({ "opencode-devcontainer.executionMode": "in-container", }); - await startBridge(bridge); + await bridge.start(); - const args = mockSpawn.mock.calls[0][1] as string[]; - const execIdx = args.indexOf("exec"); - const iIdx = args.indexOf("-i"); - const containerIdx = args.indexOf("abc123def456"); - expect(iIdx).toBeGreaterThan(execIdx); - expect(iIdx).toBeLessThan(containerIdx); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(bridge.state).toBe("idle"); }); it("fires error when no containerId", async () => { @@ -257,6 +206,77 @@ describe("start — in-container", () => { expect(listener).toHaveBeenCalledWith( expect.objectContaining({ type: "error" }) ); + expect(bridge.state).toBe("error"); + }); +}); + +// --------------------------------------------------------------------------- +// sendPrompt — per-prompt spawning +// --------------------------------------------------------------------------- + +describe("sendPrompt", () => { + it("spawns opencode run --format json -q with the prompt", async () => { + await bridge.start(); + bridge.sendPrompt("Fix the bug"); + + expect(mockSpawn).toHaveBeenCalledOnce(); + const [cmd, args] = mockSpawn.mock.calls[0]; + expect(cmd).toBe("opencode"); + expect(args).toContain("run"); + expect(args).toContain("--format"); + expect(args).toContain("json"); + expect(args).toContain("-q"); + expect(args).toContain("Fix the bug"); + }); + + it("transitions to busy state", async () => { + await bridge.start(); + bridge.sendPrompt("hello"); + expect(bridge.state).toBe("busy"); + }); + + it("includes file references in prompt text", async () => { + await bridge.start(); + bridge.sendPrompt("Fix the bug", "default", ["/src/app.ts", "/src/lib.ts"]); + + const args = mockSpawn.mock.calls[0][1] as string[]; + const promptArg = args[args.length - 1]; + expect(promptArg).toContain("@/src/app.ts"); + expect(promptArg).toContain("@/src/lib.ts"); + expect(promptArg).toContain("Fix the bug"); + }); + + it("spawns docker exec with -i in in-container mode", async () => { + __setMockConfig({ + "opencode-devcontainer.executionMode": "in-container", + }); + + await bridge.start(); + bridge.sendPrompt("Fix the bug"); + + expect(mockSpawn).toHaveBeenCalledOnce(); + const [cmd, args] = mockSpawn.mock.calls[0]; + expect(cmd).toBe("docker"); + expect(args).toContain("exec"); + expect(args).toContain("-i"); + expect(args).toContain("abc123def456"); + expect(args).toContain("opencode"); + expect(args).toContain("run"); + expect(args).toContain("Fix the bug"); + }); + + it("kills existing process before spawning new one", async () => { + await bridge.start(); + bridge.sendPrompt("first prompt"); + + const firstProcess = mockProcess; + mockProcess = createMockProcess(); + mockSpawn.mockReturnValue(mockProcess); + + bridge.sendPrompt("second prompt"); + + expect(firstProcess.kill).toHaveBeenCalled(); + expect(mockSpawn).toHaveBeenCalledTimes(2); }); }); @@ -265,8 +285,9 @@ describe("start — in-container", () => { // --------------------------------------------------------------------------- describe("stop", () => { - it("kills the process and transitions to stopped", async () => { - await startBridge(bridge); + it("kills any active process and transitions to stopped", async () => { + await bridge.start(); + bridge.sendPrompt("hello"); bridge.stop(); expect(mockProcess.kill).toHaveBeenCalled(); @@ -280,76 +301,153 @@ describe("stop", () => { }); // --------------------------------------------------------------------------- -// sendPrompt / cancelCurrentRequest / sendConfig +// cancelCurrentRequest // --------------------------------------------------------------------------- -describe("commands", () => { - it("sendPrompt writes JSON to stdin", async () => { - await startBridge(bridge); - const writeSpy = vi.spyOn(mockProcess.stdin, "write"); +describe("cancelCurrentRequest", () => { + it("kills the active process", async () => { + await bridge.start(); + bridge.sendPrompt("hello"); + bridge.cancelCurrentRequest(); - bridge.sendPrompt("Fix the bug", "default", ["/src/app.ts"]); + expect(mockProcess.kill).toHaveBeenCalled(); + }); - expect(writeSpy).toHaveBeenCalledOnce(); - const written = writeSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(written.trim()); - expect(parsed.type).toBe("prompt"); - expect(parsed.text).toBe("Fix the bug"); - expect(parsed.agent).toBe("default"); - expect(parsed.references).toEqual(["/src/app.ts"]); + it("is safe to call with no active process", async () => { + await bridge.start(); + expect(() => bridge.cancelCurrentRequest()).not.toThrow(); }); +}); - it("sendPrompt transitions to busy state", async () => { - await startBridge(bridge); +// --------------------------------------------------------------------------- +// onStateChanged +// --------------------------------------------------------------------------- + +describe("onStateChanged", () => { + it("fires when state transitions", async () => { + const listener = vi.fn(); + bridge.onStateChanged(listener); + await bridge.start(); + + expect(listener).toHaveBeenCalledWith("idle"); + }); +}); + +// --------------------------------------------------------------------------- +// NDJSON event mapping +// --------------------------------------------------------------------------- + +describe("NDJSON event mapping", () => { + it("maps text events (text → content field)", async () => { + await bridge.start(); + const listener = vi.fn(); + bridge.onEvent(listener); bridge.sendPrompt("hello"); - expect(bridge.state).toBe("busy"); + + // Simulate OpenCode emitting a text event. + mockProcess.stdout.push('{"type":"text","text":"Hello world"}\n'); + await flushStreams(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "text", + content: "Hello world", + agent: "default", + }) + ); }); - it("cancelCurrentRequest writes cancel command", async () => { - await startBridge(bridge); - const writeSpy = vi.spyOn(mockProcess.stdin, "write"); + it("maps step_start to status event", async () => { + await bridge.start(); + const listener = vi.fn(); + bridge.onEvent(listener); + bridge.sendPrompt("hello"); - bridge.cancelCurrentRequest(); + mockProcess.stdout.push( + '{"type":"step_start","message":"Analyzing code..."}\n' + ); + await flushStreams(); - expect(writeSpy).toHaveBeenCalledOnce(); - const parsed = JSON.parse((writeSpy.mock.calls[0][0] as string).trim()); - expect(parsed.type).toBe("cancel"); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "status", + message: "Analyzing code...", + }) + ); }); - it("sendConfig writes config command", async () => { - await startBridge(bridge); - const writeSpy = vi.spyOn(mockProcess.stdin, "write"); + it("maps step_finish to done event", async () => { + await bridge.start(); + const listener = vi.fn(); + bridge.onEvent(listener); + bridge.sendPrompt("hello"); - bridge.sendConfig("gpt4", "openai", "gpt-4"); + mockProcess.stdout.push('{"type":"step_finish"}\n'); + await flushStreams(); - const parsed = JSON.parse((writeSpy.mock.calls[0][0] as string).trim()); - expect(parsed.type).toBe("config"); - expect(parsed.agent).toBe("gpt4"); - expect(parsed.provider).toBe("openai"); - expect(parsed.model).toBe("gpt-4"); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ type: "done" }) + ); + expect(bridge.state).toBe("idle"); }); - it("does not write when process is not running", () => { - // Don't start the bridge + it("passes through tool_start events", async () => { + await bridge.start(); + const listener = vi.fn(); + bridge.onEvent(listener); bridge.sendPrompt("hello"); - // No crash, just silently ignored + + mockProcess.stdout.push( + '{"type":"tool_start","tool":"bash","args":{"command":"ls"}}\n' + ); + await flushStreams(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "tool_start", + tool: "bash", + args: { command: "ls" }, + }) + ); }); -}); -// --------------------------------------------------------------------------- -// onStateChanged -// --------------------------------------------------------------------------- + it("passes through error events", async () => { + await bridge.start(); + const listener = vi.fn(); + bridge.onEvent(listener); + bridge.sendPrompt("hello"); -describe("onStateChanged", () => { - it("fires when state transitions", async () => { + mockProcess.stdout.push( + '{"type":"error","message":"Something broke"}\n' + ); + await flushStreams(); + + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + message: "Something broke", + }) + ); + }); + + it("extracts text from unknown event types", async () => { + await bridge.start(); const listener = vi.fn(); - bridge.onStateChanged(listener); + bridge.onEvent(listener); + bridge.sendPrompt("hello"); - await startBridge(bridge); + mockProcess.stdout.push( + '{"type":"unknown_event","text":"Some text content"}\n' + ); + await flushStreams(); - // Should have fired with "idle" at minimum - expect(listener).toHaveBeenCalledWith("idle"); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "text", + content: "Some text content", + }) + ); }); }); @@ -359,12 +457,13 @@ describe("onStateChanged", () => { describe("stderr handling", () => { it("fires error event for lines that look like real errors", async () => { - await startBridge(bridge); + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); - // Push an error-like line through stderr. mockProcess.stderr.push("Error: something went wrong\n"); + await flushStreams(); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ @@ -375,12 +474,13 @@ describe("stderr handling", () => { }); it("fires status event for informational stderr lines", async () => { - await startBridge(bridge); + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); - // Push a non-error line through stderr (e.g. a warning or progress). mockProcess.stderr.push("Loading configuration...\n"); + await flushStreams(); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ @@ -392,23 +492,13 @@ describe("stderr handling", () => { }); it("treats FATAL lines as errors", async () => { - await startBridge(bridge); + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); mockProcess.stderr.push("FATAL: could not bind port\n"); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ type: "error" }) - ); - }); - - it("treats panic lines as errors", async () => { - await startBridge(bridge); - const listener = vi.fn(); - bridge.onEvent(listener); - - mockProcess.stderr.push("panic: runtime error\n"); + await flushStreams(); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ type: "error" }) @@ -416,11 +506,13 @@ describe("stderr handling", () => { }); it("does not fire events for empty stderr", async () => { - await startBridge(bridge); + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); mockProcess.stderr.push(" \n"); + await flushStreams(); expect(listener).not.toHaveBeenCalled(); }); @@ -438,45 +530,62 @@ describe("process exit", () => { return exitCall?.[1] as ((code: number | null) => void) | undefined; } - it("fires error event on clean exit (code 0)", async () => { - await startBridge(bridge); + it("emits done event on clean exit (code 0) when still busy", async () => { + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); const exitHandler = getExitHandler(); expect(exitHandler).toBeDefined(); exitHandler!(0); expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: expect.stringContaining("exited unexpectedly"), - }) + expect.objectContaining({ type: "done" }) ); - expect(bridge.state).toBe("stopped"); + expect(bridge.state).toBe("idle"); }); - it("fires error event on clean exit (code null)", async () => { - await startBridge(bridge); + it("emits done event on clean exit (code null) when still busy", async () => { + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); const exitHandler = getExitHandler(); exitHandler!(null); expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: expect.stringContaining("exited unexpectedly"), - }) + expect.objectContaining({ type: "done" }) ); - expect(bridge.state).toBe("stopped"); + expect(bridge.state).toBe("idle"); + }); + + it("does not double-emit done if step_finish already fired", async () => { + await bridge.start(); + const listener = vi.fn(); + bridge.onEvent(listener); + bridge.sendPrompt("hello"); + + // Simulate step_finish (maps to done) setting state to idle. + mockProcess.stdout.push('{"type":"step_finish"}\n'); + await flushStreams(); + + // Now the process exits — should NOT emit another done. + const exitHandler = getExitHandler(); + exitHandler!(0); + + const doneEvents = listener.mock.calls.filter( + ([e]: [{ type: string }]) => e.type === "done" + ); + expect(doneEvents).toHaveLength(1); }); it("fires error event on non-zero exit", async () => { - await startBridge(bridge); + await bridge.start(); const listener = vi.fn(); bridge.onEvent(listener); + bridge.sendPrompt("hello"); const exitHandler = getExitHandler(); exitHandler!(1); @@ -487,7 +596,42 @@ describe("process exit", () => { message: expect.stringContaining("exited with code 1"), }) ); - expect(bridge.state).toBe("error"); + // Bridge stays idle — ready for next prompt. + expect(bridge.state).toBe("idle"); + }); + + it("stays idle after non-zero exit (ready for next prompt)", async () => { + await bridge.start(); + bridge.sendPrompt("hello"); + + const exitHandler = getExitHandler(); + exitHandler!(1); + + expect(bridge.isRunning()).toBe(true); + expect(bridge.state).toBe("idle"); + }); +}); + +// --------------------------------------------------------------------------- +// buildPromptText +// --------------------------------------------------------------------------- + +describe("prompt text building", () => { + it("passes plain prompt when no references", async () => { + await bridge.start(); + bridge.sendPrompt("Fix the bug"); + + const args = mockSpawn.mock.calls[0][1] as string[]; + expect(args[args.length - 1]).toBe("Fix the bug"); + }); + + it("prepends @file references to prompt", async () => { + await bridge.start(); + bridge.sendPrompt("Fix it", undefined, ["/a.ts", "/b.ts"]); + + const args = mockSpawn.mock.calls[0][1] as string[]; + const promptArg = args[args.length - 1]; + expect(promptArg).toBe("@/a.ts @/b.ts\n\nFix it"); }); }); @@ -497,7 +641,8 @@ describe("process exit", () => { describe("dispose", () => { it("stops the bridge and disposes resources", async () => { - await startBridge(bridge); + await bridge.start(); + bridge.sendPrompt("hello"); expect(() => bridge.dispose()).not.toThrow(); expect(bridge.state).toBe("stopped"); }); diff --git a/src/chat/opencodeBridge.ts b/src/chat/opencodeBridge.ts index 8f2be2a..b88a9e0 100644 --- a/src/chat/opencodeBridge.ts +++ b/src/chat/opencodeBridge.ts @@ -4,18 +4,20 @@ import { createInterface } from "readline"; import { DevcontainerManager } from "../devcontainerManager"; import { getConfig, getWorkspaceFolder } from "../config"; import { writeShellWrapper, removeShellWrapper } from "../shellWrapper"; -import { OpenCodeEvent, OpenCodeCommand } from "./types"; +import { OpenCodeEvent } from "./types"; import { OpenCodeAdapter } from "./opencodeAdapter"; export type BridgeState = "idle" | "busy" | "error" | "stopped"; -/** Maximum time (ms) to wait for the process to become ready after spawn. */ -const SPAWN_READY_TIMEOUT_MS = 5_000; - /** - * Manages an OpenCode child process and exposes an event-driven + * Manages OpenCode child processes and exposes an event-driven * interface for the chat participant. * + * Each prompt spawns a new `opencode run --format json -q "prompt"` + * process. OpenCode's non-interactive mode accepts the prompt as a + * CLI argument and streams NDJSON events to stdout until the task + * completes, then exits. + * * CRITICAL: In `local-with-remote-exec` mode the bridge creates a * shell wrapper (via {@link writeShellWrapper}) and sets `SHELL` on the * child process environment so that every tool call that spawns a @@ -27,7 +29,9 @@ export class OpenCodeBridge implements vscode.Disposable { private shellWrapperPath: string | undefined; private _state: BridgeState = "stopped"; private adapter: OpenCodeAdapter; - private useJsonMode = true; + + /** Pre-computed environment for local-with-remote-exec mode. */ + private preparedEnv: Record | undefined; private readonly _onEvent = new vscode.EventEmitter(); public readonly onEvent = this._onEvent.event; @@ -52,107 +56,99 @@ export class OpenCodeBridge implements vscode.Disposable { // Lifecycle // ----------------------------------------------------------------------- + /** + * Prepare the bridge for accepting prompts. + * + * Validates that the devcontainer is available and, in + * local-with-remote-exec mode, creates the shell wrapper. + * Does NOT spawn a process — that happens per-prompt in + * {@link sendPrompt}. + */ async start(): Promise { - if (this.process) { - return; // already running + if (this._state === "idle" || this._state === "busy") { + return; // already prepared } const config = getConfig(); if (config.executionMode === "in-container") { - this.startInContainer(); + this.prepareInContainer(); } else { - this.startLocalWithRemoteExec(); - } - - // Wait until the process is confirmed alive or has already failed. - // Without this, callers see state="idle" even if the process is - // about to crash (e.g. binary not found, bad flags). - if (this.process) { - await this.waitForReady(); + this.prepareLocalWithRemoteExec(); } } - /** - * Wait a short time for the process to either stay alive or fail. - * Resolves once the process has survived the initial startup window - * or transitions to error/stopped. - */ - private waitForReady(): Promise { - return new Promise((resolve) => { - // If the process is already gone, resolve immediately. - if (!this.process || this._state === "error" || this._state === "stopped") { - resolve(); - return; - } - - let settled = false; - const settle = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - listener.dispose(); - resolve(); - }; - - // If state transitions to error/stopped, the process failed during startup. - const listener = this.onStateChanged((state) => { - if (state === "error" || state === "stopped") { - settle(); - } - }); - - // If nothing goes wrong within the timeout, the process is presumed alive. - const timer = setTimeout(settle, SPAWN_READY_TIMEOUT_MS); - }); - } - stop(): void { if (this.process) { this.process.kill(); this.process = null; } this.cleanupShellWrapper(); + this.preparedEnv = undefined; this.setState("stopped"); } isRunning(): boolean { - return this.process !== null && this._state !== "stopped"; + // The bridge is "running" when it has been prepared (idle) or is + // actively processing a prompt (busy). No long-lived process is + // required — processes are spawned per-prompt. + return this._state === "idle" || this._state === "busy"; } // ----------------------------------------------------------------------- - // Commands → OpenCode (stdin) + // Commands // ----------------------------------------------------------------------- - sendPrompt(text: string, agent?: string, references?: string[]): void { - const cmd: OpenCodeCommand = { type: "prompt", text, agent, references }; - this.sendCommand(cmd); + /** + * Send a prompt to OpenCode. + * + * Spawns `opencode run --format json -q ""` as a new child + * process. Events are streamed via {@link onEvent} until the process + * exits. + */ + sendPrompt(text: string, _agent?: string, references?: string[]): void { + // Kill any in-flight request. + if (this.process) { + this.process.kill(); + this.process = null; + } + + const promptText = this.buildPromptText(text, references); + const config = getConfig(); + + if (config.executionMode === "in-container") { + this.spawnInContainer(promptText); + } else { + this.spawnLocalWithRemoteExec(promptText); + } + this.setState("busy"); } cancelCurrentRequest(): void { - this.sendCommand({ type: "cancel" }); + if (this.process) { + this.process.kill(); + // handleExit will fire and emit an error/done event. + } } - sendConfig(agent: string, provider: string, model: string): void { - this.sendCommand({ type: "config", agent, provider, model }); + sendConfig(_agent: string, _provider: string, _model: string): void { + // No-op: per-prompt spawning does not support mid-session config + // changes. Agent selection is handled via the prompt arguments. } // ----------------------------------------------------------------------- - // Spawn strategies + // Preparation (called by start()) // ----------------------------------------------------------------------- /** - * Local-with-remote-exec mode. + * Prepare local-with-remote-exec mode. * - * OpenCode runs on the host. We set SHELL to a wrapper script that - * routes every `sh -c …` invocation through `docker exec`, ensuring - * all tool calls execute inside the devcontainer. + * Validates the container, creates the shell wrapper, and caches the + * environment variables so that each per-prompt spawn is fast. */ - private startLocalWithRemoteExec(): void { + private prepareLocalWithRemoteExec(): void { const config = getConfig(); - const workspaceFolder = getWorkspaceFolder(); - const containerId = this.devcontainerManager.containerId; const remoteWorkspace = this.devcontainerManager.remoteWorkspaceFolder; @@ -175,7 +171,7 @@ export class OpenCodeBridge implements vscode.Disposable { envToForward ); - const env: Record = { + this.preparedEnv = { ...process.env as Record, SHELL: this.shellWrapperPath, OPENCODE_DEVCONTAINER: "1", @@ -184,30 +180,55 @@ export class OpenCodeBridge implements vscode.Disposable { ...config.additionalEnvVars, }; - const args = this.buildOpenCodeArgs(); - this.spawnProcess(config.opencodePath, args, { - cwd: workspaceFolder, - env, - }); + this.setState("idle"); } /** - * In-container mode. + * Prepare in-container mode. * - * OpenCode runs entirely inside the devcontainer. No shell wrapper - * is needed because commands already execute in-container. + * Just validates that the container ID is available. */ - private startInContainer(): void { + private prepareInContainer(): void { + const containerId = this.devcontainerManager.containerId; + + if (!containerId) { + this.setState("error"); + this._onEvent.fire({ + type: "error", + message: "Dev container is not running. Start it first.", + }); + return; + } + + this.setState("idle"); + } + + // ----------------------------------------------------------------------- + // Per-prompt spawn strategies + // ----------------------------------------------------------------------- + + private spawnLocalWithRemoteExec(prompt: string): void { + const config = getConfig(); + const workspaceFolder = getWorkspaceFolder(); + + const args = ["run", "--format", "json", "-q", prompt]; + + this.spawnProcess(config.opencodePath, args, { + cwd: workspaceFolder, + env: this.preparedEnv, + }); + } + + private spawnInContainer(prompt: string): void { const config = getConfig(); const containerId = this.devcontainerManager.containerId; const remoteWorkspace = this.devcontainerManager.remoteWorkspaceFolder || "/workspaces"; if (!containerId) { - this.setState("error"); this._onEvent.fire({ type: "error", - message: "Dev container is not running. Start it first.", + message: "Dev container is not running.", }); return; } @@ -226,7 +247,11 @@ export class OpenCodeBridge implements vscode.Disposable { ...envFlags, containerId, "opencode", - ...this.buildOpenCodeArgs(), + "run", + "--format", + "json", + "-q", + prompt, ]; this.spawnProcess(config.dockerPath, args, {}); @@ -236,12 +261,6 @@ export class OpenCodeBridge implements vscode.Disposable { // Process management // ----------------------------------------------------------------------- - private buildOpenCodeArgs(): string[] { - // Attempt JSON output mode. If the flag is unsupported, the adapter - // will handle raw output. - return this.useJsonMode ? ["--format", "json"] : []; - } - private spawnProcess( command: string, args: string[], @@ -253,8 +272,6 @@ export class OpenCodeBridge implements vscode.Disposable { stdio: ["pipe", "pipe", "pipe"], }); - this.setState("idle"); - // --- stdout: line-delimited JSON events -------------------------- const rl = createInterface({ input: this.process.stdout! }); rl.on("line", (line) => this.handleStdoutLine(line)); @@ -282,27 +299,123 @@ export class OpenCodeBridge implements vscode.Disposable { // Try to parse as structured JSON first. if (trimmed.startsWith("{")) { try { - const event = JSON.parse(trimmed) as OpenCodeEvent; - if (event && typeof event.type === "string") { - if (event.type === "done") { - this.setState("idle"); + const raw = JSON.parse(trimmed) as Record; + if (raw && typeof raw.type === "string") { + const event = this.mapEvent(raw); + if (event) { + if (event.type === "done") { + this.setState("idle"); + } + this._onEvent.fire(event); + return; } - this._onEvent.fire(event); - return; } } catch { // Not valid JSON — fall through to adapter. } } - // If the first non-JSON line arrives we switch off JSON mode for - // this session and let the adapter handle all subsequent output. - if (this.useJsonMode) { - this.useJsonMode = false; - } + // Fall back to the adapter for non-JSON output. this.adapter.processLine(trimmed); } + /** + * Map an NDJSON object from the OpenCode process into our internal + * {@link OpenCodeEvent} type. + * + * OpenCode emits events like: + * { "type": "text", "text": "...", ... } + * { "type": "step_start", ... } + * { "type": "step_finish", ... } + * + * These are normalised to our event schema. + */ + private mapEvent(raw: Record): OpenCodeEvent | null { + const type = raw.type as string; + + switch (type) { + // ------ Direct matches with our event schema -------------------- + case "text": + // OpenCode uses `text`, our schema uses `content`. + return { + type: "text", + content: (raw.text ?? raw.content ?? "") as string, + agent: (raw.agent ?? "default") as string, + }; + + case "status": + return { + type: "status", + message: (raw.message ?? "") as string, + agent: (raw.agent ?? "default") as string, + }; + + case "done": + return { type: "done", agent: (raw.agent ?? "default") as string }; + + case "error": + return { + type: "error", + message: (raw.message ?? "Unknown error") as string, + }; + + case "tool_start": + return { + type: "tool_start", + tool: (raw.tool ?? raw.name ?? "unknown") as string, + args: (raw.args ?? {}) as Record, + subagentId: raw.subagentId as string | undefined, + }; + + case "tool_end": + return { + type: "tool_end", + tool: (raw.tool ?? raw.name ?? "unknown") as string, + result: (raw.result ?? "") as string, + subagentId: raw.subagentId as string | undefined, + }; + + case "subagent_start": + return { + type: "subagent_start", + id: (raw.id ?? "") as string, + name: (raw.name ?? "") as string, + parent: (raw.parent ?? "default") as string, + }; + + case "subagent_end": + return { + type: "subagent_end", + id: (raw.id ?? "") as string, + status: (raw.status ?? "completed") as + | "completed" + | "failed" + | "cancelled", + }; + + // ------ OpenCode-specific events → mapped to our schema --------- + case "step_start": + return { + type: "status", + message: (raw.message ?? "Processing...") as string, + agent: (raw.agent ?? "default") as string, + }; + + case "step_finish": + return { type: "done", agent: (raw.agent ?? "default") as string }; + + default: + // Unknown event — try to extract text content. + if (typeof raw.text === "string") { + return { type: "text", content: raw.text, agent: "default" }; + } + if (typeof raw.content === "string") { + return { type: "text", content: raw.content, agent: "default" }; + } + return null; + } + } + private handleStderr(data: string): void { const trimmed = data.trim(); if (!trimmed) { @@ -311,42 +424,44 @@ export class OpenCodeBridge implements vscode.Disposable { // Many CLI tools (including OpenCode and Docker) write informational // messages to stderr. Only treat lines that look like genuine errors - // as error events — everything else is logged but not surfaced as an - // error that would terminate the chat response. + // as error events — everything else is surfaced as status. if (this.isStderrError(trimmed)) { this._onEvent.fire({ type: "error", message: trimmed }); } else { - // Surface as a status event so the user can see it without - // terminating the response. - this._onEvent.fire({ type: "status", message: trimmed, agent: "system" }); + this._onEvent.fire({ + type: "status", + message: trimmed, + agent: "system", + }); } } /** * Heuristic: does a stderr line look like a real error? - * Lines starting with "Error:", "FATAL:", "panic:", etc. are treated - * as errors. Everything else (warnings, progress, version info) is not. */ private isStderrError(line: string): boolean { - return /^(?:Error|ERROR|FATAL|fatal|panic|PANIC|Traceback)[\s:]/i.test(line); + return /^(?:Error|ERROR|FATAL|fatal|panic|PANIC|Traceback)[\s:]/i.test( + line + ); } private handleExit(code: number | null): void { this.process = null; - this.cleanupShellWrapper(); - if (code !== 0 && code !== null) { - this._onEvent.fire({ - type: "error", - message: `OpenCode exited with code ${code}`, - }); - this.setState("error"); + if (code === 0 || code === null) { + // Normal completion. Emit "done" only if one hasn't already been + // emitted by the NDJSON stream (which would have set state to "idle"). + if (this._state === "busy") { + this._onEvent.fire({ type: "done", agent: "default" }); + } + this.setState("idle"); } else { this._onEvent.fire({ type: "error", - message: "OpenCode process exited unexpectedly", + message: `OpenCode exited with code ${code}`, }); - this.setState("stopped"); + // Stay idle so the bridge can accept the next prompt. + this.setState("idle"); } } @@ -354,11 +469,19 @@ export class OpenCodeBridge implements vscode.Disposable { // Helpers // ----------------------------------------------------------------------- - private sendCommand(cmd: OpenCodeCommand): void { - if (!this.process?.stdin?.writable) { - return; + /** + * Build the full prompt text, prepending any file references so that + * OpenCode can see them as context. + */ + private buildPromptText( + text: string, + references?: string[] + ): string { + if (!references || references.length === 0) { + return text; } - this.process.stdin.write(JSON.stringify(cmd) + "\n"); + const refList = references.map((r) => `@${r}`).join(" "); + return `${refList}\n\n${text}`; } private setState(state: BridgeState): void { diff --git a/src/chat/types.ts b/src/chat/types.ts index 706323a..888263a 100644 --- a/src/chat/types.ts +++ b/src/chat/types.ts @@ -1,9 +1,8 @@ /** * Shared types for the chat subsystem. * - * Defines the line-delimited JSON protocol between the VS Code chat - * participant and the OpenCode process, plus data structures for - * tracking agent and subagent activity. + * Defines the NDJSON event schema emitted by the OpenCode process, + * plus data structures for tracking agent and subagent activity. */ // --------------------------------------------------------------------------- @@ -33,15 +32,6 @@ export type OpenCodeEvent = | { type: "done"; agent: string } | { type: "error"; message: string }; -// --------------------------------------------------------------------------- -// Commands TO OpenCode (stdin, one JSON object per line) -// --------------------------------------------------------------------------- - -export type OpenCodeCommand = - | { type: "prompt"; text: string; agent?: string; references?: string[] } - | { type: "cancel" } - | { type: "config"; agent: string; provider: string; model: string }; - // --------------------------------------------------------------------------- // Subagent / tool-call tracking // ---------------------------------------------------------------------------