Просмотр исходного кода

run: inline files for attached servers (#33317)

Simon Klee 2 месяцев назад
Родитель
Сommit
c7efbe6fc0

+ 43 - 2
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,
           })

+ 244 - 11
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,
+  )
 })

+ 64 - 3
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<RunResult>
+}
+
 export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
 
 // 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<string, "ask" | "allow" | "deny">
   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<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
   // 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<A, E>(
       }
     })
 
-    const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
+    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<A, E>(
       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<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 argv = ["serve"]
       // 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
     })
 
-    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`