From c7efbe6fc08fa42a5e5a3485a0127867064fba71 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 22 Jun 2026 13:20:43 +0200 Subject: [PATCH] run: inline files for attached servers (#33317) --- packages/opencode/src/cli/cmd/run.ts | 45 +++- .../opencode/test/cli/run/run-process.test.ts | 255 +++++++++++++++++- packages/opencode/test/lib/cli-process.ts | 67 ++++- 3 files changed, 351 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 6f4508cb0..958632776 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,4 +1,5 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { FSUtil } from "@opencode-ai/core/fs-util" // CLI entry point for `opencode run`. // // Handles three modes: @@ -15,6 +16,7 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { open } from "node:fs/promises" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" @@ -54,6 +56,8 @@ type FilePart = { mime: string } +const ATTACH_FILE_MAX_BYTES = 10 * 1024 * 1024 + type Inline = { icon: string title: string @@ -337,11 +341,48 @@ export const RunCommand = effectCmd({ 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({ type: "file", - url: pathToFileURL(resolvedPath).href, + url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href, filename: path.basename(resolvedPath), mime, }) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index 00d2e64b3..b15cfc019 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -5,6 +5,7 @@ // `OPENCODE_CONFIG_CONTENT` providing the test provider config inline. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" describe("opencode run (non-interactive subprocess)", () => { @@ -17,7 +18,46 @@ describe("opencode run (non-interactive subprocess)", () => { yield* llm.text("hello from the test llm") const result = yield* opencode.run("say hi") 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, ) @@ -41,19 +81,24 @@ describe("opencode run (non-interactive subprocess)", () => { 30_000, ) - // Locks in the current behavior: when the LLM stream errors mid-response - // (the prompt was accepted, then the upstream provider failed), opencode - // emits a session.error event and the process exits 0 today. - // - // 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. + // The test provider's SSE error item is interpreted by the SDK as an unknown + // finish, not a fatal provider/session error. Lock that distinction in so it + // is not accidentally used as the failure compatibility oracle. cliIt.concurrent( - "mid-stream LLM error still exits 0 today (contract lock-in)", + "unknown stream finish preserves partial output and exits 0", ({ llm, opencode }) => 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") const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 }) expect(result.exitCode).toBe(0) + expect(result.stdout).toBe("partial response\n") + expect(result.stderr).not.toContain("upstream provider exploded mid-stream") }), 60_000, ) @@ -75,10 +120,198 @@ describe("opencode run (non-interactive subprocess)", () => { expect(typeof evt.type).toBe("string") expect(typeof evt.sessionID).toBe("string") } - // At least one `text` event should appear with the LLM's response. - const text = events.find((e) => e.type === "text") - expect(text).toBeDefined() + expect(events.map((event) => event.type)).toEqual(["step_start", "text", "step_finish"]) + expect(events.map(({ timestamp: _, sessionID: __, ...event }) => event)).toEqual([ + { 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, ) + + 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, + ) }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index d40a4d7f6..fa63156d8 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -82,6 +82,11 @@ export type RunResult = { readonly durationMs: number } +export type RunHandle = { + readonly interrupt: () => void + readonly result: Effect.Effect +} + export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record } // Typed equivalent of constructing argv for `opencode run`. New flags should @@ -92,6 +97,7 @@ export type RunOpts = SpawnOpts & { readonly format?: "default" | "json" readonly command?: string readonly printLogs?: boolean + readonly permission?: Record readonly extraArgs?: string[] } @@ -147,6 +153,7 @@ export type AcpHandle = { export type OpencodeCli = { // High-level: run a single prompt against the test model. Short-lived. readonly run: (message: string, opts?: RunOpts) => Effect.Effect + readonly startRun: (message: string, opts?: RunOpts) => Effect.Effect // 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`. @@ -236,7 +243,7 @@ export function withCliFixture( } }) - const run = (message: string, opts?: RunOpts): Effect.Effect => { + const runArgs = (message: string, opts?: RunOpts) => { const argv: string[] = ["run"] if (opts?.printLogs) argv.push("--print-logs") argv.push("--model", opts?.model ?? testModelID) @@ -245,9 +252,63 @@ export function withCliFixture( if (opts?.command) argv.push("--command", opts.command) if (opts?.extraArgs) argv.push(...opts.extraArgs) 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 => { + 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 argv = ["serve"] // Default port 0 — let the OS pick a free port, parse the actual one @@ -401,7 +462,7 @@ export function withCliFixture( } 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 }) // FetchHttpClient is provided so test bodies can `yield* HttpClient.HttpClient`