From cb15b3ad8432f1e9b244846aaacbf8d202bf4cbe Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 19 May 2026 08:18:22 -0400 Subject: [PATCH] test(cli): subprocess integration tests for opencode acp (#28265) --- .../opencode/test/cli/acp/acp-process.test.ts | 70 ++++++++++ packages/opencode/test/lib/cli-process.ts | 120 +++++++++++++++++- 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/cli/acp/acp-process.test.ts diff --git a/packages/opencode/test/cli/acp/acp-process.test.ts b/packages/opencode/test/cli/acp/acp-process.test.ts new file mode 100644 index 0000000000..a3c244c6c8 --- /dev/null +++ b/packages/opencode/test/cli/acp/acp-process.test.ts @@ -0,0 +1,70 @@ +// Subprocess integration tests for `opencode acp`. ACP is a JSON-RPC +// protocol spoken over stdin/stdout (not HTTP) — see src/acp/README.md. +// This is the only test tier that exercises the full pipe of bun startup → +// server boot → ACP agent init → stdio framing → graceful shutdown. +import { describe, expect } from "bun:test" +import { Duration, Effect } from "effect" +import { cliIt } from "../../lib/cli-process" + +describe("opencode acp (subprocess)", () => { + // Smoke test: send the `initialize` request from src/acp/README.md and + // assert the response advertises the same protocol version and a non-empty + // capabilities block. If this fails, every other ACP test will too — start + // debugging here. + cliIt.live( + "responds to initialize with protocolVersion 1 and capabilities", + ({ opencode }) => + Effect.gen(function* () { + const acp = yield* opencode.acp() + + yield* acp.send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: 1 }, + }) + + // Tight deadline — the response should arrive within a few seconds + // once startup completes. A hang means the agent never finished init, + // which is a real regression and not a tuning issue. + const response = (yield* acp.receive.pipe(Effect.timeout(Duration.seconds(10)))) as { + jsonrpc: string + id: number + result?: { protocolVersion: number; agentCapabilities: Record } + error?: unknown + } + + expect(response.jsonrpc).toBe("2.0") + expect(response.id).toBe(1) + expect(response.error).toBeUndefined() + expect(response.result?.protocolVersion).toBe(1) + expect(response.result?.agentCapabilities).toBeDefined() + }), + 60_000, + ) + + // Lock in the scope-close kill path. ACP's clean shutdown is "EOF on stdin" + // — if a future refactor breaks the stdin-end branch in the handler, the + // process would only exit on SIGTERM fallback (2s in the harness). This + // test passing within the inner-scope assertion proves the EOF path works. + cliIt.live( + "exits cleanly when stdin is closed (scope close)", + ({ opencode }) => + Effect.gen(function* () { + const exitedPromise = yield* Effect.scoped( + Effect.gen(function* () { + const acp = yield* opencode.acp() + // Capture the Promise — scope-close fires the finalizer which + // ends stdin, and ACP should exit gracefully. + return acp.exited + }), + ) + + const code = yield* Effect.promise(() => exitedPromise) + // Bun returns a number for normal exit. Anything goes for SIGTERM, + // but we still require resolution within the test timeout. + expect(typeof code === "number" || code === null).toBe(true) + }), + 60_000, + ) +}) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index ac03eddbf5..8481214a82 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -18,7 +18,7 @@ // without changing the fixture. Long-lived commands like `serve` will need a // different return shape — see the TODO at the bottom of OpencodeCli. import type { TestOptions } from "bun:test" -import { Deferred, Duration, Effect, Layer, Scope, Stream } from "effect" +import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import path from "node:path" import fs from "node:fs/promises" @@ -98,6 +98,28 @@ export type ServeHandle = { readonly exited: Promise } +// `opencode acp` speaks newline-delimited JSON-RPC over stdin/stdout. It is +// long-lived and exits cleanly when stdin is closed. The handle exposes the +// duplex stream as send/receive rather than raw pipes so tests don't have to +// reimplement framing on every call site. +export type AcpOpts = SpawnOpts & { + readonly cwd?: string + readonly extraArgs?: string[] +} + +export type AcpHandle = { + // Writes a single JSON-RPC message to the child's stdin as one ndjson line. + readonly send: (msg: object) => Effect.Effect + // Resolves with the next parsed JSON-RPC line from the child's stdout. + // Lines are buffered in a queue so multiple receives in a row won't drop + // anything. Pair with `Effect.timeout` if a test wants a deadline. + readonly receive: Effect.Effect + // Closes stdin. ACP exits cleanly on stdin EOF; the scope finalizer also + // calls this, so tests only need it when asserting exit behavior. + readonly close: () => void + readonly exited: Promise +} + export type OpencodeCli = { // High-level: run a single prompt against the test model. Short-lived. readonly run: (message: string, opts?: RunOpts) => Effect.Effect @@ -105,6 +127,9 @@ export type OpencodeCli = { // returned handle is killed when the caller's Scope closes. Fails if the // listening line doesn't appear within `readyTimeoutMs`. readonly serve: (opts?: ServeOpts) => Effect.Effect + // Spawn `opencode acp` and return a duplex JSON-RPC handle. Long-lived: + // the subprocess exits on stdin close, which the scope finalizer triggers. + readonly acp: (opts?: AcpOpts) => Effect.Effect // Escape hatch: any CLI invocation with full control over argv. Used to test // commands that don't yet have a typed builder. readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect @@ -260,7 +285,98 @@ export function withCliFixture( } satisfies ServeHandle }) - const opencode: OpencodeCli = { run, serve, spawn, expectExit, parseJsonEvents } + const acp = Effect.fn("opencode.acp")(function* (opts?: AcpOpts) { + const argv = ["acp"] + if (opts?.cwd) argv.push("--cwd", opts.cwd) + if (opts?.extraArgs) argv.push(...opts.extraArgs) + + // Acquire the subprocess. Release ends stdin (clean shutdown — ACP exits + // on stdin EOF) and falls back to SIGTERM if it doesn't exit promptly. + // Either way we await proc.exited so the test scope doesn't leak. + const proc = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { + cwd: opts?.cwd ?? home, + env: { ...process.env, ...env, ...opts?.env }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ), + (p) => + // Graceful shutdown: close stdin (ACP exits on EOF), give it a + // window to exit, then SIGTERM. The Effect.timeoutOrElse expresses + // exactly that race without raw setTimeout or Promise.race. + Effect.gen(function* () { + yield* Effect.sync(() => p.stdin.end()) + yield* Effect.promise(() => p.exited).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(2), + orElse: () => + Effect.sync(() => { + p.kill() + }), + }), + ) + yield* Effect.promise(() => p.exited) + }).pipe(Effect.ignore), + ) + + const stderrChunks: string[] = [] + yield* Effect.forkScoped( + Stream.fromReadableStream({ + evaluate: () => proc.stderr, + onError: () => new Error("stderr stream error"), + }).pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => Effect.sync(() => stderrChunks.push(chunk))), + Effect.ignore, + ), + ) + + // Each ndjson line becomes one queue entry. JSON.parse failures are + // surfaced as the raw string so a malformed protocol message doesn't + // silently wedge the test in `receive`. + const responses = yield* Queue.unbounded() + yield* Effect.forkScoped( + Stream.fromReadableStream({ + evaluate: () => proc.stdout, + onError: () => new Error("stdout stream error"), + }).pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => { + if (line.length === 0) return Effect.void + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + parsed = { _rawLine: line } + } + return Queue.offer(responses, parsed) + }), + Effect.ignore, + ), + ) + + return { + send: (msg: object) => + Effect.sync(() => { + proc.stdin.write(JSON.stringify(msg) + "\n") + }), + receive: Queue.take(responses), + close: () => { + try { + proc.stdin.end() + } catch { + // already closed + } + }, + exited: proc.exited as Promise, + } satisfies AcpHandle + }) + + const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents } return yield* fn({ llm, home, opencode }) // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`