run: inline files for attached servers (#33317)
This commit is contained in:
parent
cd292a4ecb
commit
c7efbe6fc0
3 changed files with 351 additions and 16 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
// CLI entry point for `opencode run`.
|
// CLI entry point for `opencode run`.
|
||||||
//
|
//
|
||||||
// Handles three modes:
|
// Handles three modes:
|
||||||
|
|
@ -15,6 +16,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||||
import type { Argv } from "yargs"
|
import type { Argv } from "yargs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
|
import { open } from "node:fs/promises"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { effectCmd } from "../effect-cmd"
|
import { effectCmd } from "../effect-cmd"
|
||||||
|
|
@ -54,6 +56,8 @@ type FilePart = {
|
||||||
mime: string
|
mime: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
|
||||||
type Inline = {
|
type Inline = {
|
||||||
icon: string
|
icon: string
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -337,11 +341,48 @@ export const RunCommand = effectCmd({
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain"
|
const stat = Filesystem.stat(resolvedPath)
|
||||||
|
const isDirectory = stat?.isDirectory() ?? false
|
||||||
|
if (args.attach && isDirectory) {
|
||||||
|
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await (async () => {
|
||||||
|
if (!args.attach) return
|
||||||
|
const handle = await open(resolvedPath, "r")
|
||||||
|
try {
|
||||||
|
const opened = await handle.stat()
|
||||||
|
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
|
||||||
|
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (opened.size === 0) return Buffer.alloc(0)
|
||||||
|
const buffer = Buffer.alloc(Number(opened.size))
|
||||||
|
let offset = 0
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
|
||||||
|
if (read.bytesRead === 0) break
|
||||||
|
offset += read.bytesRead
|
||||||
|
}
|
||||||
|
return buffer.subarray(0, offset)
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const detected = FSUtil.mimeType(resolvedPath)
|
||||||
|
const text = content?.toString("utf8")
|
||||||
|
const mime = !args.attach
|
||||||
|
? isDirectory
|
||||||
|
? "application/x-directory"
|
||||||
|
: "text/plain"
|
||||||
|
: content && text !== undefined && Buffer.from(text, "utf8").equals(content)
|
||||||
|
? "text/plain"
|
||||||
|
: detected
|
||||||
|
|
||||||
files.push({
|
files.push({
|
||||||
type: "file",
|
type: "file",
|
||||||
url: pathToFileURL(resolvedPath).href,
|
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href,
|
||||||
filename: path.basename(resolvedPath),
|
filename: path.basename(resolvedPath),
|
||||||
mime,
|
mime,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { reply } from "../../lib/llm-server"
|
||||||
import { cliIt } from "../../lib/cli-process"
|
import { cliIt } from "../../lib/cli-process"
|
||||||
|
|
||||||
describe("opencode run (non-interactive subprocess)", () => {
|
describe("opencode run (non-interactive subprocess)", () => {
|
||||||
|
|
@ -17,7 +18,46 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||||
yield* llm.text("hello from the test llm")
|
yield* llm.text("hello from the test llm")
|
||||||
const result = yield* opencode.run("say hi")
|
const result = yield* opencode.run("say hi")
|
||||||
opencode.expectExit(result, 0)
|
opencode.expectExit(result, 0)
|
||||||
expect(result.stdout).toContain("hello from the test llm")
|
expect(result.stdout).toBe("hello from the test llm\n")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"prints each completed text part in order around a tool continuation",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.push(
|
||||||
|
reply().text(" before tool ").tool("bash", {
|
||||||
|
command: "printf tool-output",
|
||||||
|
description: "Print deterministic output",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* llm.text(" after tool ")
|
||||||
|
|
||||||
|
const result = yield* opencode.run("use a tool", {
|
||||||
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
|
})
|
||||||
|
|
||||||
|
opencode.expectExit(result, 0)
|
||||||
|
expect(result.stdout).toBe("before tool\nafter tool\n")
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"prints reasoning before text only with --thinking",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.reason(" considering ", { text: " answer " })
|
||||||
|
const thinking = yield* opencode.run("think", { extraArgs: ["--thinking"] })
|
||||||
|
opencode.expectExit(thinking, 0)
|
||||||
|
expect(thinking.stdout).toBe("Thinking: considering\nanswer\n")
|
||||||
|
|
||||||
|
yield* llm.reason("hidden", { text: "visible" })
|
||||||
|
const plain = yield* opencode.run("think again")
|
||||||
|
opencode.expectExit(plain, 0)
|
||||||
|
expect(plain.stdout).toBe("visible\n")
|
||||||
}),
|
}),
|
||||||
60_000,
|
60_000,
|
||||||
)
|
)
|
||||||
|
|
@ -41,19 +81,24 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||||
30_000,
|
30_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Locks in the current behavior: when the LLM stream errors mid-response
|
// The test provider's SSE error item is interpreted by the SDK as an unknown
|
||||||
// (the prompt was accepted, then the upstream provider failed), opencode
|
// finish, not a fatal provider/session error. Lock that distinction in so it
|
||||||
// emits a session.error event and the process exits 0 today.
|
// is not accidentally used as the failure compatibility oracle.
|
||||||
//
|
|
||||||
// This is debatable — a future cleanup might flip it to exit 1. If you're
|
|
||||||
// changing this expectation, do it deliberately and say so in the PR.
|
|
||||||
cliIt.concurrent(
|
cliIt.concurrent(
|
||||||
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
"unknown stream finish preserves partial output and exits 0",
|
||||||
({ llm, opencode }) =>
|
({ llm, opencode }) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
yield* llm.push(
|
||||||
|
reply().text("partial response").tool("bash", {
|
||||||
|
command: "printf tool",
|
||||||
|
description: "Print deterministic output",
|
||||||
|
}),
|
||||||
|
)
|
||||||
yield* llm.fail("upstream provider exploded mid-stream")
|
yield* llm.fail("upstream provider exploded mid-stream")
|
||||||
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
|
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
|
||||||
expect(result.exitCode).toBe(0)
|
expect(result.exitCode).toBe(0)
|
||||||
|
expect(result.stdout).toBe("partial response\n")
|
||||||
|
expect(result.stderr).not.toContain("upstream provider exploded mid-stream")
|
||||||
}),
|
}),
|
||||||
60_000,
|
60_000,
|
||||||
)
|
)
|
||||||
|
|
@ -75,10 +120,198 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||||
expect(typeof evt.type).toBe("string")
|
expect(typeof evt.type).toBe("string")
|
||||||
expect(typeof evt.sessionID).toBe("string")
|
expect(typeof evt.sessionID).toBe("string")
|
||||||
}
|
}
|
||||||
// At least one `text` event should appear with the LLM's response.
|
expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"])
|
||||||
const text = events.find((e) => e.type === "text")
|
expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([
|
||||||
expect(text).toBeDefined()
|
{ type: "step_start", part: expect.objectContaining({ type: "step-start" }) },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
part: expect.objectContaining({ type: "text", text: "structured output" }),
|
||||||
|
},
|
||||||
|
{ type: "step_finish", part: expect.objectContaining({ type: "step-finish" }) },
|
||||||
|
])
|
||||||
|
expect(result.stdout.endsWith("\n")).toBe(true)
|
||||||
|
expect(result.stdout.split("\n").slice(0, -1).every((line) => line.length > 0)).toBe(true)
|
||||||
}),
|
}),
|
||||||
60_000,
|
60_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"--format json emits a pure error record for a rejected prompt request",
|
||||||
|
({ opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* opencode.run("use an unknown model", {
|
||||||
|
model: "test/nonexistent-model",
|
||||||
|
format: "json",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.exitCode).not.toBe(0)
|
||||||
|
const events = opencode.parseJsonEvents(result.stdout)
|
||||||
|
expect(events.map((event) => event.type)).toEqual(["error"])
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
type: "error",
|
||||||
|
timestamp: expect.any(Number),
|
||||||
|
sessionID: expect.any(String),
|
||||||
|
error: expect.any(Object),
|
||||||
|
})
|
||||||
|
expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"--format json preserves reasoning, tool, and continuation ordering",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.push(
|
||||||
|
reply().reason("reasoning").text("before").tool("bash", {
|
||||||
|
command: "printf tool",
|
||||||
|
description: "Print deterministic output",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* llm.text("after")
|
||||||
|
|
||||||
|
const result = yield* opencode.run("exercise json records", {
|
||||||
|
format: "json",
|
||||||
|
extraArgs: ["--thinking", "--dangerously-skip-permissions"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0)
|
||||||
|
const events = opencode.parseJsonEvents(result.stdout)
|
||||||
|
expect(events.map((event) => event.type)).toEqual([
|
||||||
|
"step_start",
|
||||||
|
"reasoning",
|
||||||
|
"text",
|
||||||
|
"tool_use",
|
||||||
|
"step_finish",
|
||||||
|
"step_start",
|
||||||
|
"text",
|
||||||
|
"step_finish",
|
||||||
|
])
|
||||||
|
expect(events.find((event) => event.type === "reasoning")?.part).toEqual(
|
||||||
|
expect.objectContaining({ type: "reasoning", text: "reasoning" }),
|
||||||
|
)
|
||||||
|
expect(events.find((event) => event.type === "tool_use")?.part).toEqual(
|
||||||
|
expect.objectContaining({ type: "tool", tool: "bash", state: expect.objectContaining({ status: "completed" }) }),
|
||||||
|
)
|
||||||
|
expect(result.stdout.split("\n").slice(0, -1).every((line) => line.startsWith("{"))).toBe(true)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"--format json records partial output for an unknown stream finish",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.push(
|
||||||
|
reply().text("partial json").tool("bash", {
|
||||||
|
command: "printf tool",
|
||||||
|
description: "Print deterministic output",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* llm.fail("provider failed")
|
||||||
|
const result = yield* opencode.run("fail after output", { format: "json" })
|
||||||
|
|
||||||
|
const events = opencode.parseJsonEvents(result.stdout)
|
||||||
|
expect(result.exitCode).toBe(0)
|
||||||
|
expect(events.map((event) => event.type)).toEqual([
|
||||||
|
"step_start",
|
||||||
|
"text",
|
||||||
|
"tool_use",
|
||||||
|
"step_finish",
|
||||||
|
"step_start",
|
||||||
|
"step_finish",
|
||||||
|
])
|
||||||
|
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
|
||||||
|
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"rejects requested permissions by default and allows them with the dangerous flag",
|
||||||
|
({ home, llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" })
|
||||||
|
yield* llm.text("continued after rejection")
|
||||||
|
const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } })
|
||||||
|
opencode.expectExit(denied, 0)
|
||||||
|
expect(denied.stderr).toContain("permission requested: bash")
|
||||||
|
expect(denied.stdout).toBe("")
|
||||||
|
|
||||||
|
yield* llm.reset
|
||||||
|
yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" })
|
||||||
|
yield* llm.text("continued after approval")
|
||||||
|
const allowed = yield* opencode.run("request permission", {
|
||||||
|
permission: { bash: "ask" },
|
||||||
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
|
})
|
||||||
|
opencode.expectExit(allowed, 0)
|
||||||
|
expect(allowed.stderr).not.toContain("permission requested: bash")
|
||||||
|
expect(allowed.stdout).toContain("continued after approval")
|
||||||
|
|
||||||
|
yield* llm.reset
|
||||||
|
yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" })
|
||||||
|
yield* llm.text("continued after explicit denial")
|
||||||
|
const explicitlyDenied = yield* opencode.run("request denied permission", {
|
||||||
|
permission: { bash: "deny" },
|
||||||
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
|
})
|
||||||
|
opencode.expectExit(explicitlyDenied, 0)
|
||||||
|
expect(explicitlyDenied.stdout).toContain("continued after explicit denial")
|
||||||
|
expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.live(
|
||||||
|
"attach mode sends client-local file contents without a shared path",
|
||||||
|
({ home, llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const source = `${home}/client-only.txt`
|
||||||
|
const sentinel = "client-only attachment sentinel"
|
||||||
|
yield* Effect.promise(() => Bun.write(source, sentinel))
|
||||||
|
yield* llm.text("attachment received")
|
||||||
|
const server = yield* opencode.serve()
|
||||||
|
|
||||||
|
const result = yield* opencode.run("read the attachment", {
|
||||||
|
extraArgs: ["--attach", server.url, `--file=${source}`, "--"],
|
||||||
|
})
|
||||||
|
|
||||||
|
opencode.expectExit(result, 0)
|
||||||
|
const input = JSON.stringify(yield* llm.inputs)
|
||||||
|
expect(input).toContain(sentinel)
|
||||||
|
expect(input).not.toContain(`file://${source}`)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.concurrent(
|
||||||
|
"attach mode rejects local directories before prompt admission",
|
||||||
|
({ home, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const result = yield* opencode.run("read the directory", {
|
||||||
|
extraArgs: ["--attach", "http://127.0.0.1:1", `--file=${home}`, "--"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.exitCode).not.toBe(0)
|
||||||
|
expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem")
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
cliIt.live(
|
||||||
|
"SIGINT interrupts an active non-interactive run without leaking the process",
|
||||||
|
({ llm, opencode }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* llm.hang
|
||||||
|
const run = yield* opencode.startRun("wait forever")
|
||||||
|
yield* llm.wait(1)
|
||||||
|
run.interrupt()
|
||||||
|
const result = yield* run.result
|
||||||
|
|
||||||
|
expect(result.exitCode).not.toBe(0)
|
||||||
|
expect(result.durationMs).toBeLessThan(30_000)
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,11 @@ export type RunResult = {
|
||||||
readonly durationMs: number
|
readonly durationMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RunHandle = {
|
||||||
|
readonly interrupt: () => void
|
||||||
|
readonly result: Effect.Effect<RunResult>
|
||||||
|
}
|
||||||
|
|
||||||
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
||||||
|
|
||||||
// Typed equivalent of constructing argv for `opencode run`. New flags should
|
// Typed equivalent of constructing argv for `opencode run`. New flags should
|
||||||
|
|
@ -92,6 +97,7 @@ export type RunOpts = SpawnOpts & {
|
||||||
readonly format?: "default" | "json"
|
readonly format?: "default" | "json"
|
||||||
readonly command?: string
|
readonly command?: string
|
||||||
readonly printLogs?: boolean
|
readonly printLogs?: boolean
|
||||||
|
readonly permission?: Record<string, "ask" | "allow" | "deny">
|
||||||
readonly extraArgs?: string[]
|
readonly extraArgs?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,6 +153,7 @@ export type AcpHandle = {
|
||||||
export type OpencodeCli = {
|
export type OpencodeCli = {
|
||||||
// High-level: run a single prompt against the test model. Short-lived.
|
// High-level: run a single prompt against the test model. Short-lived.
|
||||||
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
||||||
|
readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect<RunHandle, never, Scope.Scope>
|
||||||
// Spawn `opencode serve` and wait until it's listening. Long-lived: the
|
// 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
|
// returned handle is killed when the caller's Scope closes. Fails if the
|
||||||
// listening line doesn't appear within `readyTimeoutMs`.
|
// listening line doesn't appear within `readyTimeoutMs`.
|
||||||
|
|
@ -236,7 +243,7 @@ export function withCliFixture<A, E>(
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
|
const runArgs = (message: string, opts?: RunOpts) => {
|
||||||
const argv: string[] = ["run"]
|
const argv: string[] = ["run"]
|
||||||
if (opts?.printLogs) argv.push("--print-logs")
|
if (opts?.printLogs) argv.push("--print-logs")
|
||||||
argv.push("--model", opts?.model ?? testModelID)
|
argv.push("--model", opts?.model ?? testModelID)
|
||||||
|
|
@ -245,9 +252,63 @@ export function withCliFixture<A, E>(
|
||||||
if (opts?.command) argv.push("--command", opts.command)
|
if (opts?.command) argv.push("--command", opts.command)
|
||||||
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
||||||
argv.push(message)
|
argv.push(message)
|
||||||
return spawn(argv, opts)
|
return argv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runOpts = (opts?: RunOpts): SpawnOpts | undefined => {
|
||||||
|
if (!opts?.permission) return opts
|
||||||
|
return {
|
||||||
|
...opts,
|
||||||
|
env: {
|
||||||
|
...opts.env,
|
||||||
|
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||||
|
...testProviderConfig(llm.url),
|
||||||
|
permission: opts.permission,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
|
||||||
|
return spawn(
|
||||||
|
runArgs(message, opts),
|
||||||
|
runOpts(opts),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) {
|
||||||
|
const start = Date.now()
|
||||||
|
const options = runOpts(opts)
|
||||||
|
const proc = yield* Effect.acquireRelease(
|
||||||
|
Effect.sync(() =>
|
||||||
|
Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], {
|
||||||
|
cwd: home,
|
||||||
|
env: { ...process.env, ...env, ...options?.env },
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(child) =>
|
||||||
|
Effect.promise(() => {
|
||||||
|
child.kill()
|
||||||
|
return child.exited
|
||||||
|
}).pipe(Effect.ignore),
|
||||||
|
)
|
||||||
|
const stdout = new Response(proc.stdout).text()
|
||||||
|
const stderr = new Response(proc.stderr).text()
|
||||||
|
|
||||||
|
return {
|
||||||
|
interrupt: () => proc.kill("SIGINT"),
|
||||||
|
result: Effect.promise(async () => ({
|
||||||
|
exitCode: await proc.exited,
|
||||||
|
stdout: await stdout,
|
||||||
|
stderr: await stderr,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
})),
|
||||||
|
} satisfies RunHandle
|
||||||
|
})
|
||||||
|
|
||||||
const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
|
const serve = Effect.fn("opencode.serve")(function* (opts?: ServeOpts) {
|
||||||
const argv = ["serve"]
|
const argv = ["serve"]
|
||||||
// Default port 0 — let the OS pick a free port, parse the actual one
|
// Default port 0 — let the OS pick a free port, parse the actual one
|
||||||
|
|
@ -401,7 +462,7 @@ export function withCliFixture<A, E>(
|
||||||
} satisfies AcpHandle
|
} satisfies AcpHandle
|
||||||
})
|
})
|
||||||
|
|
||||||
const opencode: OpencodeCli = { run, serve, acp, spawn, expectExit, parseJsonEvents }
|
const opencode: OpencodeCli = { run, startRun, serve, acp, spawn, expectExit, parseJsonEvents }
|
||||||
|
|
||||||
return yield* fn({ llm, home, opencode })
|
return yield* fn({ llm, home, opencode })
|
||||||
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
|
// FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue