Files
anomalyco_opencode/packages/opencode/test/cli/acp/acp-process.test.ts
T
Kit Langton 75c507f769 refactor(test/cli): migrate serve/acp builders to AppProcess.spawn
Slice 2 of the CLI harness Effect migration. Drops the last raw
Bun.spawn call sites in withCliFixture.

- `serve` and `acp` both move from `Effect.acquireRelease(Bun.spawn(...))`
  to `appProc.spawn(ChildProcess.make(...))`. The spawner's built-in
  acquireRelease finalizer handles SIGTERM on scope close — no manual
  wiring needed.

- `handle.stdout` / `handle.stderr` are already Effect Streams, so the
  `fromBunStream` helper is gone (Stream.fromReadableStream + the
  per-pipe error-tag boilerplate it wrapped).

- acp's stdin moves from imperative `proc.stdin.write` + `proc.stdin.end`
  to a Queue<Uint8Array> fed into the spawner's stdin Sink via
  Stream.fromQueue. `send` is `Queue.offer`, `close` is `Queue.shutdown` —
  shutdown propagates as stdin EOF, which is ACP's graceful-exit signal.

- ServeHandle/AcpHandle public shape: `kill`/`close` become
  Effect<void> and `exited` becomes Effect<number> (was () => void and
  Promise<number>). The platform error that cross-spawn-spawner raises
  on signal-kill is collapsed to exit code -1 so `exited` stays a clean
  Effect<number> — matches the test contract (just needs proof of exit).

Two consuming tests updated to yield the Effect instead of awaiting
the Promise.
2026-05-19 16:19:04 -04:00

73 lines
2.9 KiB
TypeScript

// 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 exited = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp()
// Capture the Effect — scope-close shuts down stdinQueue, which
// propagates as stdin EOF; ACP exits gracefully. The exitCode
// Effect itself has no Scope requirement so yielding it after
// scope close is safe.
return acp.exited
}),
)
const code = yield* exited
// Signal-killed processes surface as -1; clean EOF gives 0. Either
// way we just need a number — proves the process exited.
expect(typeof code).toBe("number")
}),
60_000,
)
})