mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 19:35:10 +00:00
test(cli): subprocess integration tests for opencode acp (#28265)
This commit is contained in:
70
packages/opencode/test/cli/acp/acp-process.test.ts
Normal file
70
packages/opencode/test/cli/acp/acp-process.test.ts
Normal file
@@ -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<string, unknown> }
|
||||
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,
|
||||
)
|
||||
})
|
||||
@@ -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<number>
|
||||
}
|
||||
|
||||
// `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<void>
|
||||
// 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<unknown>
|
||||
// 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<number>
|
||||
}
|
||||
|
||||
export type OpencodeCli = {
|
||||
// High-level: run a single prompt against the test model. Short-lived.
|
||||
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
||||
@@ -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<ServeHandle, Error, Scope.Scope>
|
||||
// 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<AcpHandle, Error, Scope.Scope>
|
||||
// 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<RunResult>
|
||||
@@ -260,7 +285,98 @@ export function withCliFixture<A, E>(
|
||||
} 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<unknown>()
|
||||
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<number>,
|
||||
} 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`
|
||||
|
||||
Reference in New Issue
Block a user