feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture

Forked from OpenCode v1.17.4 with multi-agent system:
- 5 agents: aircoding, scheduler, worker, architect, reviewer
- Deterministic DAG scheduling engine (coordinator_tick)
- Tool whitelists as hard enforcement
- AirCoding validation plugin
- V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md
- Design documents in docs/
This commit is contained in:
airlongdian
2026-06-13 21:53:43 +08:00
commit deaf7eb806
5757 changed files with 1169969 additions and 0 deletions

View File

@@ -0,0 +1,459 @@
// 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:
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
// - OPENCODE_PURE : skip external plugin discovery + install
// - OPENCODE_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 {
OPENCODE_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"),
OPENCODE_CONFIG_CONTENT: configJson,
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_DISABLE_AUTOUPDATE: "1",
OPENCODE_DISABLE_AUTOCOMPACT: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_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),
}

View File

@@ -0,0 +1,177 @@
import { test, type TestOptions } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { Cause, Duration, Effect, Exit, Layer } from "effect"
import * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import type { Config } from "@/config/config"
import { TestInstance, withTmpdirInstance } from "../fixture/fixture"
import { InstanceStore } from "@/project/instance-store"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
type InstanceOptions<E, R> = {
git?: boolean
config?: Partial<ConfigV1.Info> | (() => Partial<ConfigV1.Info>)
init?: (directory: string) => Effect.Effect<void, E, R>
}
function isInstanceOptions<E, R>(
options: InstanceOptions<E, R> | number | TestOptions | undefined,
): options is InstanceOptions<E, R> {
return !!options && typeof options === "object" && ("git" in options || "config" in options || "init" in options)
}
function instanceArgs<E, R>(
options?: InstanceOptions<E, R> | number | TestOptions,
testOptions?: number | TestOptions,
): { instanceOptions: InstanceOptions<E, R> | undefined; testOptions: number | TestOptions | undefined } {
if (typeof options === "number") return { instanceOptions: undefined, testOptions: options }
if (isInstanceOptions(options)) return { instanceOptions: options, testOptions }
return { instanceOptions: undefined, testOptions: options }
}
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
type Runner = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) => Promise<A>
const isolatedRun: Runner = (value, layer) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
// Builds the test layer through the shared process-wide memoMap so cached
// services (Bus, Session, …) match Server.Default's instances. Use for tests
// that publish to an in-process HTTP server and need pub/sub identity with
// the server's handlers.
const sharedRun: Runner = (value, layer) =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const ctx = yield* Layer.buildWithMemoMap(layer, memoMap, scope)
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(ctx), Effect.exit)
yield* Scope.close(scope, Exit.void)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>, run: Runner = isolatedRun) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, testLayer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, testLayer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, testLayer), opts)
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, liveLayer), opts)
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, liveLayer), opts)
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts)
const instance = <A, E2, E3 = never>(
name: string,
value: Body<A, E2, R | InstanceStore.Service | TestInstance | Scope.Scope>,
options?: InstanceOptions<E3, R | Scope.Scope> | number | TestOptions,
opts?: number | TestOptions,
) => {
const args = instanceArgs(options, opts)
return test(
name,
() => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer),
args.testOptions,
)
}
instance.only = <A, E2, E3 = never>(
name: string,
value: Body<A, E2, R | InstanceStore.Service | TestInstance | Scope.Scope>,
options?: InstanceOptions<E3, R | Scope.Scope> | number | TestOptions,
opts?: number | TestOptions,
) => {
const args = instanceArgs(options, opts)
return test.only(
name,
() => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer),
args.testOptions,
)
}
instance.skip = <A, E2, E3 = never>(
name: string,
value: Body<A, E2, R | InstanceStore.Service | TestInstance | Scope.Scope>,
options?: InstanceOptions<E3, R | Scope.Scope> | number | TestOptions,
opts?: number | TestOptions,
) => {
const args = instanceArgs(options, opts)
return test.skip(
name,
() => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer),
args.testOptions,
)
}
return { effect, live, instance }
}
// Test environment with TestClock and TestConsole
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
// Live environment - uses real clock, but keeps TestConsole for output capture
const liveEnv = TestConsole.layer
export const it = make<never, never>(testEnv, liveEnv)
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
// Variant of `testEffect` that builds the test layer through the shared
// process-wide memoMap so services like Bus/Session resolve to the same
// instances Server.Default uses. Use when a test needs pub/sub identity with
// an in-process HTTP server — most tests should stick with `testEffect`.
export const testEffectShared = <R, E>(layer: Layer.Layer<R, E>) =>
make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun)
export const awaitWithTimeout = <A, E, R>(
self: Effect.Effect<A, E, R>,
message: string,
duration: Duration.Input = "2 seconds",
) =>
self.pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(message)),
}),
)
export const pollWithTimeout = <A, E, R>(
self: Effect.Effect<A | undefined, E, R>,
message: string,
duration: Duration.Input = "5 seconds",
) =>
Effect.gen(function* () {
while (true) {
const result = yield* self
if (result !== undefined) return result
yield* Effect.sleep("20 millis")
}
}).pipe(
Effect.timeoutOrElse({
duration,
orElse: () => Effect.fail(new Error(message)),
}),
)

View File

@@ -0,0 +1,10 @@
import path from "path"
import { Effect, FileSystem } from "effect"
export const writeFileStringScoped = Effect.fn("test.writeFileStringScoped")(function* (file: string, text: string) {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(file, text)
yield* Effect.addFinalizer(() => fs.remove(file, { force: true }).pipe(Effect.orDie))
return file
})

View File

@@ -0,0 +1,779 @@
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import * as Http from "node:http"
import { Deferred, Effect, Layer, Context, Stream } from "effect"
import * as HttpServer from "effect/unstable/http/HttpServer"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
export type Usage = { input: number; output: number }
type Line = Record<string, unknown>
type Flow =
| { type: "text"; text: string }
| { type: "reason"; text: string }
| { type: "tool-start"; id: string; name: string }
| { type: "tool-args"; text: string }
| { type: "usage"; usage: Usage }
type Hit = {
url: URL
body: Record<string, unknown>
}
type Match = (hit: Hit) => boolean
type Queue = {
item: Item
match?: Match
}
type Wait = {
count: number
ready: Deferred.Deferred<void>
}
type Sse = {
type: "sse"
head: unknown[]
tail: unknown[]
wait?: PromiseLike<unknown>
hang?: boolean
error?: unknown
reset?: boolean
}
type HttpError = {
type: "http-error"
status: number
body: unknown
}
export type Item = Sse | HttpError
const done = Symbol("done")
function line(input: unknown) {
if (input === done) return "data: [DONE]\n\n"
return `data: ${JSON.stringify(input)}\n\n`
}
function tokens(input?: Usage) {
if (!input) return
return {
prompt_tokens: input.input,
completion_tokens: input.output,
total_tokens: input.input + input.output,
}
}
function chunk(input: { delta?: Record<string, unknown>; finish?: string; usage?: Usage }) {
return {
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [
{
delta: input.delta ?? {},
...(input.finish ? { finish_reason: input.finish } : {}),
},
],
...(input.usage ? { usage: tokens(input.usage) } : {}),
} satisfies Line
}
function role() {
return chunk({ delta: { role: "assistant" } })
}
function textLine(value: string) {
return chunk({ delta: { content: value } })
}
function reasonLine(value: string) {
return chunk({ delta: { reasoning_content: value } })
}
function finishLine(reason: string, usage?: Usage) {
return chunk({ finish: reason, usage })
}
function toolStartLine(id: string, name: string) {
return chunk({
delta: {
tool_calls: [
{
index: 0,
id,
type: "function",
function: {
name,
arguments: "",
},
},
],
},
})
}
function toolArgsLine(value: string) {
return chunk({
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: value,
},
},
],
},
})
}
function bytes(input: Iterable<unknown>) {
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
}
function responseCreated(model: string) {
return {
type: "response.created",
sequence_number: 1,
response: {
id: "resp_test",
created_at: Math.floor(Date.now() / 1000),
model,
service_tier: null,
},
}
}
function responseCompleted(input: { seq: number; usage?: Usage }) {
return {
type: "response.completed",
sequence_number: input.seq,
response: {
incomplete_details: null,
service_tier: null,
usage: {
input_tokens: input.usage?.input ?? 0,
input_tokens_details: { cached_tokens: null },
output_tokens: input.usage?.output ?? 0,
output_tokens_details: { reasoning_tokens: null },
},
},
}
}
function responseMessage(id: string, seq: number) {
return {
type: "response.output_item.added",
sequence_number: seq,
output_index: 0,
item: { type: "message", id },
}
}
function responseText(id: string, text: string, seq: number) {
return {
type: "response.output_text.delta",
sequence_number: seq,
item_id: id,
delta: text,
logprobs: null,
}
}
function responseMessageDone(id: string, seq: number) {
return {
type: "response.output_item.done",
sequence_number: seq,
output_index: 0,
item: { type: "message", id },
}
}
function responseReason(id: string, seq: number) {
return {
type: "response.output_item.added",
sequence_number: seq,
output_index: 0,
item: { type: "reasoning", id, encrypted_content: null },
}
}
function responseReasonPart(id: string, seq: number) {
return {
type: "response.reasoning_summary_part.added",
sequence_number: seq,
item_id: id,
summary_index: 0,
}
}
function responseReasonText(id: string, text: string, seq: number) {
return {
type: "response.reasoning_summary_text.delta",
sequence_number: seq,
item_id: id,
summary_index: 0,
delta: text,
}
}
function responseReasonDone(id: string, seq: number) {
return {
type: "response.output_item.done",
sequence_number: seq,
output_index: 0,
item: { type: "reasoning", id, encrypted_content: null },
}
}
function responseTool(id: string, item: string, name: string, seq: number) {
return {
type: "response.output_item.added",
sequence_number: seq,
output_index: 0,
item: {
type: "function_call",
id: item,
call_id: id,
name,
arguments: "",
status: "in_progress",
},
}
}
function responseToolArgs(id: string, text: string, seq: number) {
return {
type: "response.function_call_arguments.delta",
sequence_number: seq,
output_index: 0,
item_id: id,
delta: text,
}
}
function responseToolArgsDone(id: string, args: string, seq: number) {
return {
type: "response.function_call_arguments.done",
sequence_number: seq,
output_index: 0,
item_id: id,
arguments: args,
}
}
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
return {
type: "response.output_item.done",
sequence_number: seq,
output_index: 0,
item: {
type: "function_call",
id: tool.item,
call_id: tool.id,
name: tool.name,
arguments: tool.args,
status: "completed",
},
}
}
function choices(part: unknown) {
if (!part || typeof part !== "object") return
if (!("choices" in part) || !Array.isArray(part.choices)) return
const choice = part.choices[0]
if (!choice || typeof choice !== "object") return
return choice
}
function flow(item: Sse) {
const out: Flow[] = []
for (const part of [...item.head, ...item.tail]) {
const choice = choices(part)
const delta =
choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
if (delta && "content" in delta && typeof delta.content === "string") {
out.push({ type: "text", text: delta.content })
}
if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
out.push({ type: "reason", text: delta.reasoning_content })
}
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
for (const tool of delta.tool_calls) {
if (!tool || typeof tool !== "object") continue
const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
out.push({ type: "tool-start", id: tool.id, name: fn.name })
}
if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
out.push({ type: "tool-args", text: fn.arguments })
}
}
}
if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
const raw = part.usage as Record<string, unknown>
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
out.push({
type: "usage",
usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
})
}
}
}
return out
}
function responses(item: Sse, model: string) {
let seq = 1
let msg: string | undefined
let reason: string | undefined
let hasMsg = false
let hasReason = false
let call:
| {
id: string
item: string
name: string
args: string
}
| undefined
let usage: Usage | undefined
const lines: unknown[] = [responseCreated(model)]
for (const part of flow(item)) {
if (part.type === "text") {
msg ??= "msg_1"
if (!hasMsg) {
hasMsg = true
seq += 1
lines.push(responseMessage(msg, seq))
}
seq += 1
lines.push(responseText(msg, part.text, seq))
continue
}
if (part.type === "reason") {
reason ||= "rs_1"
if (!hasReason) {
hasReason = true
seq += 1
lines.push(responseReason(reason, seq))
seq += 1
lines.push(responseReasonPart(reason, seq))
}
seq += 1
lines.push(responseReasonText(reason, part.text, seq))
continue
}
if (part.type === "tool-start") {
call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
seq += 1
lines.push(responseTool(call.id, call.item, call.name, seq))
continue
}
if (part.type === "tool-args") {
if (!call) continue
call.args += part.text
seq += 1
lines.push(responseToolArgs(call.item, part.text, seq))
continue
}
usage = part.usage
}
if (msg) {
seq += 1
lines.push(responseMessageDone(msg, seq))
}
if (reason) {
seq += 1
lines.push(responseReasonDone(reason, seq))
}
if (call && !item.hang && !item.error) {
seq += 1
lines.push(responseToolArgsDone(call.item, call.args, seq))
seq += 1
lines.push(responseToolDone(call, seq))
}
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
return { ...item, head: lines, tail: [] } satisfies Sse
}
function modelFrom(body: unknown) {
if (!body || typeof body !== "object") return "test-model"
if (!("model" in body) || typeof body.model !== "string") return "test-model"
return body.model
}
function send(item: Sse) {
const head = bytes(item.head)
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
const empty = Stream.fromIterable<Uint8Array>([])
const wait = item.wait
const body: Stream.Stream<Uint8Array, unknown> = wait
? Stream.concat(head, Stream.fromEffect(Effect.promise(() => wait)).pipe(Stream.flatMap(() => tail)))
: Stream.concat(head, tail)
let end: Stream.Stream<Uint8Array, unknown> = empty
if (item.error) end = Stream.concat(empty, Stream.fail(item.error))
else if (item.hang) end = Stream.concat(empty, Stream.never)
return HttpServerResponse.stream(Stream.concat(body, end), { contentType: "text/event-stream" })
}
const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
const req = yield* HttpServerRequest.HttpServerRequest
const res = NodeHttpServerRequest.toServerResponse(req)
yield* Effect.sync(() => {
res.writeHead(200, { "content-type": "text/event-stream" })
for (const part of item.head) res.write(line(part))
for (const part of item.tail) res.write(line(part))
res.destroy(new Error("connection reset"))
})
return yield* Effect.never
})
function fail(item: HttpError) {
return HttpServerResponse.text(JSON.stringify(item.body), {
status: item.status,
contentType: "application/json",
})
}
export class Reply {
#head: unknown[] = [role()]
#tail: unknown[] = []
#usage: Usage | undefined
#finish: string | undefined
#wait: PromiseLike<unknown> | undefined
#hang = false
#error: unknown
#reset = false
#seq = 0
#id() {
this.#seq += 1
return `call_${this.#seq}`
}
text(value: string) {
this.#tail = [...this.#tail, textLine(value)]
return this
}
reason(value: string) {
this.#tail = [...this.#tail, reasonLine(value)]
return this
}
usage(value: Usage) {
this.#usage = value
return this
}
wait(value: PromiseLike<unknown>) {
this.#wait = value
return this
}
stop() {
this.#finish = "stop"
this.#hang = false
this.#error = undefined
this.#reset = false
return this
}
contentFilter() {
this.#finish = "content_filter"
this.#hang = false
this.#error = undefined
this.#reset = false
return this
}
toolCalls() {
this.#finish = "tool_calls"
this.#hang = false
this.#error = undefined
this.#reset = false
return this
}
tool(name: string, input: unknown) {
const id = this.#id()
const args = JSON.stringify(input)
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args)]
return this.toolCalls()
}
pendingTool(name: string, input: unknown) {
const id = this.#id()
const args = JSON.stringify(input)
const size = Math.max(1, Math.floor(args.length / 2))
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args.slice(0, size))]
return this
}
hang() {
this.#finish = undefined
this.#hang = true
this.#error = undefined
this.#reset = false
return this
}
streamError(error: unknown = "boom") {
this.#finish = undefined
this.#hang = false
this.#error = error
this.#reset = false
return this
}
reset() {
this.#finish = undefined
this.#hang = false
this.#error = undefined
this.#reset = true
return this
}
item(): Item {
return {
type: "sse",
head: this.#head,
tail: this.#finish ? [...this.#tail, finishLine(this.#finish, this.#usage)] : this.#tail,
wait: this.#wait,
hang: this.#hang,
error: this.#error,
reset: this.#reset,
}
}
}
export function reply() {
return new Reply()
}
export function httpError(status: number, body: unknown): Item {
return {
type: "http-error",
status,
body,
}
}
export function raw(input: {
chunks?: unknown[]
head?: unknown[]
tail?: unknown[]
wait?: PromiseLike<unknown>
hang?: boolean
error?: unknown
reset?: boolean
}): Item {
return {
type: "sse",
head: input.head ?? input.chunks ?? [],
tail: input.tail ?? [],
wait: input.wait,
hang: input.hang,
error: input.error,
reset: input.reset,
}
}
function item(input: Item | Reply) {
return input instanceof Reply ? input.item() : input
}
function hit(url: string, body: unknown) {
return {
url: new URL(url, "http://localhost"),
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
} satisfies Hit
}
function isTitleRequest(body: unknown): boolean {
if (!body || typeof body !== "object") return false
return JSON.stringify(body).includes("Generate a title for this conversation")
}
namespace TestLLMServer {
export interface Service {
readonly url: string
readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
readonly pushMatch: (match: Match, ...input: (Item | Reply)[]) => Effect.Effect<void>
readonly textMatch: (match: Match, value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
readonly toolMatch: (match: Match, name: string, input: unknown) => Effect.Effect<void>
readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
readonly tool: (name: string, input: unknown) => Effect.Effect<void>
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
readonly fail: (message?: unknown) => Effect.Effect<void>
readonly error: (status: number, body: unknown) => Effect.Effect<void>
readonly hang: Effect.Effect<void>
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
readonly reset: Effect.Effect<void>
readonly hits: Effect.Effect<Hit[]>
readonly calls: Effect.Effect<number>
readonly wait: (count: number) => Effect.Effect<void>
readonly inputs: Effect.Effect<Record<string, unknown>[]>
readonly pending: Effect.Effect<number>
readonly misses: Effect.Effect<Hit[]>
}
}
export class TestLLMServer extends Context.Service<TestLLMServer, TestLLMServer.Service>()("@test/LLMServer") {
static readonly layer = Layer.effect(
TestLLMServer,
Effect.gen(function* () {
const server = yield* HttpServer.HttpServer
const router = yield* HttpRouter.HttpRouter
let hits: Hit[] = []
let list: Queue[] = []
let waits: Wait[] = []
let misses: Hit[] = []
const queue = (...input: (Item | Reply)[]) => {
list = [...list, ...input.map((value) => ({ item: item(value) }))]
}
const queueMatch = (match: Match, ...input: (Item | Reply)[]) => {
list = [...list, ...input.map((value) => ({ item: item(value), match }))]
}
const notify = Effect.fnUntraced(function* () {
const ready = waits.filter((item) => hits.length >= item.count)
if (!ready.length) return
waits = waits.filter((item) => hits.length < item.count)
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
})
const pull = (hit: Hit) => {
const index = list.findIndex((entry) => !entry.match || entry.match(hit))
if (index === -1) return
const first = list[index]
list = [...list.slice(0, index), ...list.slice(index + 1)]
return first.item
}
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
const req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
const current = hit(req.originalUrl, body)
if (isTitleRequest(body)) {
hits = [...hits, current]
yield* notify()
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
if (mode === "responses") return send(responses(auto, modelFrom(body)))
return send(auto)
}
const next = pull(current)
if (!next) {
hits = [...hits, current]
yield* notify()
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("ok"), finishLine("stop")] }
if (mode === "responses") return send(responses(auto, modelFrom(body)))
return send(auto)
}
hits = [...hits, current]
yield* notify()
if (next.type !== "sse") return fail(next)
if (mode === "responses") return send(responses(next, modelFrom(body)))
if (next.reset) {
yield* reset(next)
return HttpServerResponse.empty()
}
return send(next)
})
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
yield* router.add("POST", "/v1/responses", handle("responses"))
yield* server.serve(router.asHttpEffect())
return TestLLMServer.of({
url:
server.address._tag === "TcpAddress"
? `http://127.0.0.1:${server.address.port}/v1`
: `unix://${server.address.path}/v1`,
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
queue(...input)
}),
pushMatch: Effect.fn("TestLLMServer.pushMatch")(function* (match: Match, ...input: (Item | Reply)[]) {
queueMatch(match, ...input)
}),
textMatch: Effect.fn("TestLLMServer.textMatch")(function* (
match: Match,
value: string,
opts?: { usage?: Usage },
) {
const out = reply().text(value)
if (opts?.usage) out.usage(opts.usage)
queueMatch(match, out.stop().item())
}),
toolMatch: Effect.fn("TestLLMServer.toolMatch")(function* (match: Match, name: string, input: unknown) {
queueMatch(match, reply().tool(name, input).item())
}),
text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
const out = reply().text(value)
if (opts?.usage) out.usage(opts.usage)
queue(out.stop().item())
}),
tool: Effect.fn("TestLLMServer.tool")(function* (name: string, input: unknown) {
queue(reply().tool(name, input).item())
}),
toolHang: Effect.fn("TestLLMServer.toolHang")(function* (name: string, input: unknown) {
queue(reply().pendingTool(name, input).hang().item())
}),
reason: Effect.fn("TestLLMServer.reason")(function* (value: string, opts?: { text?: string; usage?: Usage }) {
const out = reply().reason(value)
if (opts?.text) out.text(opts.text)
if (opts?.usage) out.usage(opts.usage)
queue(out.stop().item())
}),
fail: Effect.fn("TestLLMServer.fail")(function* (message: unknown = "boom") {
queue(reply().streamError(message).item())
}),
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
queue(httpError(status, body))
}),
hang: Effect.gen(function* () {
queue(reply().hang().item())
}).pipe(Effect.withSpan("TestLLMServer.hang")),
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
queue(reply().wait(wait).text(value).stop().item())
}),
reset: Effect.sync(() => {
hits = []
list = []
waits = []
misses = []
}),
hits: Effect.sync(() => [...hits]),
calls: Effect.sync(() => hits.length),
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
if (hits.length >= count) return
const ready = yield* Deferred.make<void>()
waits = [...waits, { count, ready }]
yield* Deferred.await(ready)
}),
inputs: Effect.sync(() => hits.map((hit) => hit.body)),
pending: Effect.sync(() => list.length),
misses: Effect.sync(() => [...misses]),
})
}),
).pipe(Layer.provide(HttpRouter.layer), Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })))
}

View File

@@ -0,0 +1,73 @@
// Shared normalization helpers for cross-OS-stable snapshot tests.
//
// Every snapshot test that captures subprocess output, file paths, or other
// OS-flavored strings hits the same two issues:
// 1. Bun emits CRLF line endings on Windows stderr; LF elsewhere.
// 2. Path separators differ (\ on Windows, / on POSIX), and macOS's
// /var/folders symlink resolves to /private/var/folders.
//
// These helpers exist so each test doesn't reinvent the same regexes.
//
// Use individually for fine-grained control, or compose them via
// `normalizeForSnapshot` for the common "snapshot subprocess output" path.
import fs from "node:fs"
import os from "node:os"
const TMP = os.tmpdir()
const REAL_TMP = fs.realpathSync(TMP)
/**
* Collapses CRLF to LF. Bun's subprocess pipes emit native line endings —
* snapshots captured on macOS/Linux contain LF, so a Windows run without
* this step always diffs.
*/
export function stripCrlf(text: string): string {
return text.replaceAll("\r\n", "\n")
}
/**
* Converts Windows-style `\` separators to POSIX `/` so paths render
* identically across OSes. Use for path strings you want stable in a
* snapshot, not for filesystem operations.
*/
export function toPosixPath(p: string): string {
return p.replaceAll("\\", "/")
}
/**
* Strips both the OS-level `os.tmpdir()` and its realpath form (macOS
* `/var/folders` → `/private/var/folders`) from text, replacing each
* occurrence with `marker` (default `<TMPDIR>`).
*/
export function withTmpdirStripped(text: string, marker = "<TMPDIR>"): string {
return text.replaceAll(REAL_TMP, marker).replaceAll(TMP, marker)
}
/**
* Separator-agnostic match class for path-style strings. Use inside a
* larger regex when you want to match both `/` (POSIX) and `\` (Windows)
* boundaries — e.g. `<TMPDIR>${PATH_SEP}oc-cli-[a-z0-9]+`.
*/
export const PATH_SEP = "[/\\\\]"
/**
* One-shot normalization for the common case: strip CRLF, strip tmpdir,
* then apply any caller-supplied path regex substitutions. Does NOT
* blanket-replace `\` with `/` — that would mangle non-path backslash
* content (regex literals in help text, etc.). Use `toPosixPath` or
* `PATH_SEP` in your own regex when you need separator agnosticism.
*/
export function normalizeForSnapshot(
text: string,
options?: {
readonly tmpdirMarker?: string
readonly pathReplacements?: ReadonlyArray<readonly [RegExp, string]>
},
): string {
let out = stripCrlf(text)
out = withTmpdirStripped(out, options?.tmpdirMarker)
for (const [pattern, replacement] of options?.pathReplacements ?? []) {
out = out.replace(pattern, replacement)
}
return out
}

View File

@@ -0,0 +1,37 @@
// Shared provider config for tests that need opencode to talk to a fake LLM
// over a real HTTP endpoint. Registers a single provider `test` with a single
// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the
// caller supplies (typically a TestLLMServer instance).
//
// Used by:
// - test/lib/run-process.ts (subprocess CLI tests)
// - test/server/httpapi-sdk.test.ts (in-process SDK tests)
export function testProviderConfig(llmUrl: string) {
return {
formatter: false,
lsp: false,
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100_000, output: 10_000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: { apiKey: "test-key", baseURL: llmUrl },
},
},
}
}

View File

@@ -0,0 +1,46 @@
export class FakeWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = FakeWebSocket.CONNECTING
closed = false
sent: string[] = []
listeners = new Map<string, Set<(event: { data?: unknown }) => void>>()
constructor(
readonly url: string,
readonly options?: { headers?: Record<string, string> },
) {}
addEventListener(type: string, listener: (event: { data?: unknown }) => void) {
const current = this.listeners.get(type) ?? new Set<(event: { data?: unknown }) => void>()
current.add(listener)
this.listeners.set(type, current)
}
send(data: string) {
this.sent.push(data)
}
close() {
if (this.readyState === FakeWebSocket.CLOSED) return
this.closed = true
this.readyState = FakeWebSocket.CLOSED
this.emit("close", {})
}
open() {
this.readyState = FakeWebSocket.OPEN
this.emit("open", {})
}
message(data: unknown) {
this.emit("message", { data })
}
emit(type: string, event: { data?: unknown }) {
this.listeners.get(type)?.forEach((listener) => listener(event))
}
}