mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-22 06:30:39 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
460 lines
20 KiB
TypeScript
460 lines
20 KiB
TypeScript
// Subprocess test harness for the opencode CLI. Spawns the real binary against
|
|
// a TestLLMServer running in-process at a random port, with full env isolation.
|
|
//
|
|
// This is the missing test tier: in-process tests can't catch bugs that span
|
|
// argv parsing → server boot → SDK call → event consumption → exit code (like
|
|
// the original /event race or #27371's invalid-model hang).
|
|
//
|
|
// Configuration flows through opencode's built-in test affordances:
|
|
// - KILO_CONFIG_CONTENT : provider config inline, no files to find
|
|
// - KILO_TEST_HOME : pins os.homedir() → tmpdir
|
|
// - KILO_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
|
|
// - KILO_PURE : skip external plugin discovery + install
|
|
// - KILO_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
|
|
// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
|
|
//
|
|
// Today only `opencode.run` is fully wired. The shape supports adding more
|
|
// builders (`opencode.serve(opts)`, `opencode.acp(opts)`, `opencode.auth(...)`)
|
|
// without changing the fixture. Long-lived commands like `serve` will need a
|
|
// different return shape — see the TODO at the bottom of OpencodeCli.
|
|
import { test, type TestOptions } from "bun:test"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { AppProcess } from "@opencode-ai/core/process"
|
|
import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect"
|
|
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
|
|
import { ChildProcess } from "effect/unstable/process"
|
|
import path from "node:path"
|
|
import { TestLLMServer } from "./llm-server"
|
|
import { testProviderConfig } from "./test-provider"
|
|
import { it } from "./effect"
|
|
|
|
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
|
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
|
|
|
export const testModelID = "test/test-model"
|
|
|
|
// Wrap a Bun subprocess pipe (or any ReadableStream<Uint8Array>) as a Stream.
|
|
// Centralizes the `evaluate` + `onError` boilerplate and tags errors with the
|
|
// stream name so a stderr/stdout failure is greppable in logs.
|
|
function fromBunStream(name: string, get: () => ReadableStream<Uint8Array>) {
|
|
return Stream.fromReadableStream({
|
|
evaluate: get,
|
|
onError: (cause) => new Error(`${name} stream error: ${String(cause)}`),
|
|
})
|
|
}
|
|
|
|
// Long-lived processes (serve, acp) all want the same stderr drain: read every
|
|
// chunk, push to a tail buffer, swallow stream errors (the child closing the
|
|
// pipe is normal). `log: true` surfaces a real protocol error to logs so a
|
|
// regression doesn't silently disappear.
|
|
function forkStderrDrain(stream: ReadableStream<Uint8Array>, into: string[]) {
|
|
return Effect.forkScoped(
|
|
fromBunStream("stderr", () => stream).pipe(
|
|
Stream.decodeText(),
|
|
Stream.runForEach((chunk) => Effect.sync(() => into.push(chunk))),
|
|
Effect.ignore({ log: true }),
|
|
),
|
|
)
|
|
}
|
|
|
|
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
|
return {
|
|
KILO_TEST_HOME: home,
|
|
HOME: home,
|
|
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
XDG_DATA_HOME: path.join(home, ".local/share"),
|
|
XDG_STATE_HOME: path.join(home, ".local/state"),
|
|
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
KILO_CONFIG_CONTENT: configJson,
|
|
KILO_DISABLE_PROJECT_CONFIG: "1",
|
|
KILO_PURE: "1",
|
|
KILO_DISABLE_AUTOUPDATE: "1",
|
|
KILO_DISABLE_AUTOCOMPACT: "1",
|
|
KILO_DISABLE_MODELS_FETCH: "1",
|
|
KILO_AUTH_CONTENT: "{}",
|
|
}
|
|
}
|
|
|
|
export type RunResult = {
|
|
readonly exitCode: number
|
|
readonly stdout: string
|
|
readonly stderr: string
|
|
readonly durationMs: number
|
|
}
|
|
|
|
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
|
|
|
// Typed equivalent of constructing argv for `opencode run`. New flags should
|
|
// land here so tests stay grep-able and refactor-safe.
|
|
export type RunOpts = SpawnOpts & {
|
|
readonly model?: string
|
|
readonly agent?: string
|
|
readonly format?: "default" | "json"
|
|
readonly command?: string
|
|
readonly printLogs?: boolean
|
|
readonly extraArgs?: string[]
|
|
}
|
|
|
|
// `opencode serve` is a long-lived process — it never exits on its own.
|
|
// `serve(opts)` therefore returns a handle inside the caller's Scope: the
|
|
// subprocess is killed when the scope closes (test end), and the URL the
|
|
// server actually bound to (port 0 means OS-assigned) is parsed off stdout.
|
|
export type ServeOpts = SpawnOpts & {
|
|
readonly port?: number
|
|
readonly hostname?: string
|
|
readonly extraArgs?: string[]
|
|
// How long to wait for the "listening on http://..." line before failing.
|
|
// Default 15s — startup is dominated by bun's transpile + plugin init, not
|
|
// the actual listen() call.
|
|
readonly readyTimeoutMs?: number
|
|
}
|
|
|
|
export type ServeHandle = {
|
|
// Full URL the server is bound to, e.g. "http://127.0.0.1:54321". Use this
|
|
// as the base for HTTP requests in tests — never assume the port.
|
|
readonly url: string
|
|
readonly hostname: string
|
|
readonly port: number
|
|
// Sends SIGTERM. The scope finalizer also calls this, so tests rarely need
|
|
// to invoke it directly — useful for tests that assert exit behavior.
|
|
readonly kill: () => void
|
|
// Resolves with the exit code once the process exits. Bun returns a number.
|
|
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>
|
|
// Spawn `opencode serve` and wait until it's listening. Long-lived: the
|
|
// 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>
|
|
// Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
|
|
// failures are debuggable without re-running locally.
|
|
readonly expectExit: (result: RunResult, expected: number, label?: string) => void
|
|
// Parse `--format json` stdout into one event object per non-empty line.
|
|
// The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
|
|
// event (see src/cli/cmd/run.ts `emit`). Throws on a malformed line so
|
|
// tests fail loudly rather than silently skipping data.
|
|
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
|
|
}
|
|
|
|
export type CliFixture = {
|
|
readonly llm: TestLLMServer["Service"]
|
|
readonly home: string
|
|
readonly opencode: OpencodeCli
|
|
}
|
|
|
|
// Provisions a TestLLMServer + tmpdir + spawn helper and invokes fn. Cleans
|
|
// up the tmpdir on scope exit. TestLLMServer.layer is provided internally so
|
|
// the caller doesn't need to wire it up — the fixture's lifetime is tied to
|
|
// the surrounding Scope.
|
|
export function withCliFixture<A, E>(
|
|
fn: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
|
|
): Effect.Effect<A, E | unknown, Scope.Scope> {
|
|
return Effect.gen(function* () {
|
|
const llm = yield* TestLLMServer
|
|
const fs = yield* FSUtil.Service
|
|
const appProc = yield* AppProcess.Service
|
|
|
|
// FileSystem.makeTempDirectoryScoped handles both creation and scope-tied
|
|
// cleanup — replaces the old mkdir + addFinalizer pair.
|
|
const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" })
|
|
|
|
const configJson = JSON.stringify(testProviderConfig(llm.url))
|
|
const env = isolatedEnv(home, configJson)
|
|
|
|
const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) {
|
|
const start = Date.now()
|
|
const timeoutMs = opts?.timeoutMs ?? 30_000
|
|
// stdin: "ignore" so the child doesn't see a piped stdin and block
|
|
// on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is
|
|
// consumed as the prompt). The old Process.run wrapper defaulted to
|
|
// ignore; ChildProcess.make defaults to pipe, so we set it explicitly.
|
|
const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], {
|
|
cwd: home,
|
|
env: { ...env, ...opts?.env },
|
|
extendEnv: true,
|
|
stdin: "ignore",
|
|
})
|
|
// Pass timeout to appProc.run rather than wrapping with
|
|
// Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so
|
|
// its built-in timeout triggers the acquireRelease kill finalizer
|
|
// inside cross-spawn-spawner *before* surfacing the AppProcessError —
|
|
// guaranteeing the child is dead by the time the test continues.
|
|
// External timeoutOrElse interrupts the run fiber but races the
|
|
// scope close, which can leak the child past the test boundary.
|
|
//
|
|
// Catch AppProcessError (timeout OR spawn failure) and synthesize a
|
|
// non-zero result so the test sees it via the usual `expectExit`
|
|
// path rather than as an unhandled Effect failure.
|
|
const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe(
|
|
Effect.catchTag("AppProcessError", (err) =>
|
|
Effect.succeed({
|
|
command: err.command,
|
|
exitCode: err.exitCode ?? -1,
|
|
stdout: Buffer.alloc(0),
|
|
stderr: Buffer.from((err.stderr ?? String(err.cause ?? err.message)) + "\n"),
|
|
stdoutTruncated: false,
|
|
stderrTruncated: false,
|
|
} satisfies AppProcess.RunResult),
|
|
),
|
|
)
|
|
return {
|
|
exitCode: result.exitCode,
|
|
stdout: result.stdout.toString(),
|
|
stderr: result.stderr.toString(),
|
|
durationMs: Date.now() - start,
|
|
}
|
|
})
|
|
|
|
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
|
|
const argv: string[] = ["run"]
|
|
if (opts?.printLogs) argv.push("--print-logs")
|
|
argv.push("--model", opts?.model ?? testModelID)
|
|
if (opts?.agent) argv.push("--agent", opts.agent)
|
|
if (opts?.format) argv.push("--format", opts.format)
|
|
if (opts?.command) argv.push("--command", opts.command)
|
|
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
|
argv.push(message)
|
|
return spawn(argv, opts)
|
|
}
|
|
|
|
const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
|
|
const argv = ["serve"]
|
|
// Default port 0 — let the OS pick a free port, parse the actual one
|
|
// off stdout. Hard-coded ports flake under parallel tests.
|
|
argv.push("--port", String(opts?.port ?? 0))
|
|
if (opts?.hostname) argv.push("--hostname", opts.hostname)
|
|
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
|
|
|
// Acquire the subprocess; release sends SIGTERM and awaits exit on
|
|
// scope close. Wrapped in Effect.ignore so a flaky kill doesn't surface
|
|
// as a finalizer error during test teardown.
|
|
const proc = yield* Effect.acquireRelease(
|
|
Effect.sync(() =>
|
|
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], {
|
|
cwd: home,
|
|
env: { ...process.env, ...env, ...opts?.env },
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
}),
|
|
),
|
|
(p) =>
|
|
Effect.promise(() => {
|
|
p.kill()
|
|
return p.exited
|
|
}).pipe(Effect.ignore),
|
|
)
|
|
|
|
// Tail buffer so timeout failures can include stderr context. The fork
|
|
// also keeps the OS pipe buffer from filling and wedging the child.
|
|
const stderrChunks: string[] = []
|
|
yield* forkStderrDrain(proc.stderr, stderrChunks)
|
|
|
|
// Watch stdout line-by-line for the listening sentinel. Format
|
|
// (see src/cli/cmd/serve.ts):
|
|
// "opencode server listening on http://<host>:<port>"
|
|
const readyRe = /listening on (http:\/\/([^\s:]+):(\d+))/
|
|
const readyDeferred = yield* Deferred.make<{ url: string; hostname: string; port: number }>()
|
|
yield* Effect.forkScoped(
|
|
fromBunStream("stdout", () => proc.stdout).pipe(
|
|
Stream.decodeText(),
|
|
Stream.splitLines,
|
|
Stream.runForEach((line) => {
|
|
const m = line.match(readyRe)
|
|
return m ? Deferred.succeed(readyDeferred, { url: m[1], hostname: m[2], port: Number(m[3]) }) : Effect.void
|
|
}),
|
|
Effect.ignore({ log: true }),
|
|
),
|
|
)
|
|
|
|
const readyTimeoutMs = opts?.readyTimeoutMs ?? 15_000
|
|
const match = yield* Deferred.await(readyDeferred).pipe(
|
|
Effect.timeoutOrElse({
|
|
duration: Duration.millis(readyTimeoutMs),
|
|
orElse: () =>
|
|
Effect.fail(
|
|
new Error(
|
|
`opencode serve did not become ready within ${readyTimeoutMs}ms\n` +
|
|
`stderr (last 2000):\n${stderrChunks.join("").slice(-2000)}`,
|
|
),
|
|
),
|
|
}),
|
|
)
|
|
|
|
return {
|
|
url: match.url,
|
|
hostname: match.hostname,
|
|
port: match.port,
|
|
kill: () => {
|
|
proc.kill()
|
|
},
|
|
exited: proc.exited as Promise<number>,
|
|
} satisfies ServeHandle
|
|
})
|
|
|
|
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* forkStderrDrain(proc.stderr, stderrChunks)
|
|
|
|
// 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(
|
|
fromBunStream("stdout", () => proc.stdout).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({ log: true }),
|
|
),
|
|
)
|
|
|
|
return {
|
|
// `proc.stdin.write` returns `number | Promise<number>`. The promise
|
|
// form is the backpressure signal — if we don't await it, rapid
|
|
// successive sends can interleave under pipe-buffer-full conditions
|
|
// and corrupt the ndjson framing.
|
|
send: (msg: object) =>
|
|
Effect.promise(async () => {
|
|
const ret = proc.stdin.write(JSON.stringify(msg) + "\n")
|
|
if (typeof ret !== "number") await ret
|
|
}),
|
|
receive: Queue.take(responses),
|
|
// proc.stdin.end() is idempotent in Bun; no try/catch needed.
|
|
close: () => proc.stdin.end(),
|
|
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`
|
|
// and hit endpoints on `opencode.serve()` without rolling their own fetch.
|
|
}).pipe(
|
|
Effect.provide(
|
|
Layer.mergeAll(TestLLMServer.layer, FetchHttpClient.layer, FSUtil.defaultLayer, AppProcess.defaultLayer),
|
|
),
|
|
)
|
|
}
|
|
|
|
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
|
|
return stdout
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0)
|
|
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
|
}
|
|
|
|
// Convenience for the common assertion pattern. Dumps stderr/stdout when
|
|
// the exit code doesn't match — saves debugging time on CI failures.
|
|
function expectExit(result: RunResult, expected: number, label = "opencode") {
|
|
if (result.exitCode === expected) return
|
|
const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
|
|
throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
|
|
}
|
|
|
|
// `cliIt.live(name, fixture => effect)` is the same as
|
|
// `it.live(name, () => withCliFixture(fixture))` — one fewer nesting level at
|
|
// every call site. Use this for any test that needs the opencode CLI fixture.
|
|
//
|
|
// Subprocess tests must run against the real clock — a TestClock-paused
|
|
// environment can't drive a child process. If you need `.only` or `.skip`, fall
|
|
// back to `it.live` + `withCliFixture` directly.
|
|
// Body's R is `Scope.Scope | never` so tests can yield* scope-requiring
|
|
// resources (e.g. `opencode.serve`) without an extra `Effect.scoped` wrapper —
|
|
// `withCliFixture`'s outer scope is the natural lifetime.
|
|
export const cliIt = {
|
|
live: <A, E>(
|
|
name: string,
|
|
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
|
|
opts?: number | TestOptions,
|
|
) => it.live(name, () => withCliFixture(body), opts),
|
|
concurrent: <A, E>(
|
|
name: string,
|
|
body: (input: CliFixture) => Effect.Effect<A, E, Scope.Scope | HttpClient.HttpClient>,
|
|
opts?: number | TestOptions,
|
|
) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts),
|
|
}
|