feat: 品牌替换 + 启动优化 + AGENTS.md 模板定制
- 品牌替换:OpenCode/opencode → AirCoding/aircoding(16+ 文件) - Logo ASCII art:修复 left/right 行数不匹配导致的启动崩溃 - 启动诊断:添加 OPENCODE_PRINT_TIMING 计时探针 - dev 模式默认 --pure 跳过外部插件加载 - AGENTS.md 模板:追加 AirCoding 多 Agent 专项段落 - architect prompt + plugin:强化 AGENTS.md 产出验证
This commit is contained in:
459
packages/opencode/test/lib/cli-process.ts
Normal file
459
packages/opencode/test/lib/cli-process.ts
Normal 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),
|
||||
}
|
||||
177
packages/opencode/test/lib/effect.ts
Normal file
177
packages/opencode/test/lib/effect.ts
Normal 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)),
|
||||
}),
|
||||
)
|
||||
10
packages/opencode/test/lib/filesystem.ts
Normal file
10
packages/opencode/test/lib/filesystem.ts
Normal 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
|
||||
})
|
||||
779
packages/opencode/test/lib/llm-server.ts
Normal file
779
packages/opencode/test/lib/llm-server.ts
Normal 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 })))
|
||||
}
|
||||
73
packages/opencode/test/lib/snapshot.ts
Normal file
73
packages/opencode/test/lib/snapshot.ts
Normal 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
|
||||
}
|
||||
37
packages/opencode/test/lib/test-provider.ts
Normal file
37
packages/opencode/test/lib/test-provider.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
46
packages/opencode/test/lib/websocket.ts
Normal file
46
packages/opencode/test/lib/websocket.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user