From 696ba14d9ab796be99b5fd73fd6e025a49de0fea Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 30 Jan 2026 15:43:29 -0500 Subject: [PATCH 1/7] ci: increase ARM runner to 8 vCPUs for faster Tauri builds --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3974d23ffc1f..a1b492258b73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-4vcpu-ubuntu-2404-arm + - host: blacksmith-8vcpu-ubuntu-2404-arm target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: From 08382c74817e5f3a651e2f9c23c418df5b6eeb9b Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Wed, 22 Apr 2026 13:31:04 +0300 Subject: [PATCH 2/7] refactor(acp): extract tool and command formatting into dedicated modules --- packages/opencode/src/acp/parse-command.ts | 21 + packages/opencode/src/acp/tool-format.ts | 449 ++++++++++++++++++ .../opencode/test/acp/parse-command.test.ts | 32 ++ 3 files changed, 502 insertions(+) create mode 100644 packages/opencode/src/acp/parse-command.ts create mode 100644 packages/opencode/src/acp/tool-format.ts create mode 100644 packages/opencode/test/acp/parse-command.test.ts diff --git a/packages/opencode/src/acp/parse-command.ts b/packages/opencode/src/acp/parse-command.ts new file mode 100644 index 000000000000..433cd09d9570 --- /dev/null +++ b/packages/opencode/src/acp/parse-command.ts @@ -0,0 +1,21 @@ +import type { ToolKind } from "@agentclientprotocol/sdk" + +export namespace ParseCommand { + export interface Result { + kind: ToolKind + title: string + locations: { path: string }[] + terminalOutput: boolean + } + + export function format(command: string, description: string, cwd: string): Result { + const title = description || command || "Terminal" + + return { + kind: "other", + title, + locations: cwd ? [{ path: cwd }] : [], + terminalOutput: true, + } + } +} diff --git a/packages/opencode/src/acp/tool-format.ts b/packages/opencode/src/acp/tool-format.ts new file mode 100644 index 000000000000..5a89e569c40c --- /dev/null +++ b/packages/opencode/src/acp/tool-format.ts @@ -0,0 +1,449 @@ +import type { ToolCallContent, ToolKind } from "@agentclientprotocol/sdk" +import { ParseCommand } from "./parse-command" + +export interface ToolCallInfo { + title: string + kind: ToolKind + content: ToolCallContent[] + locations: { path: string; line?: number }[] + rawInput: unknown +} + +export interface ToolResultInfo { + content: ToolCallContent[] + rawOutput: unknown + title?: string +} + +function normalize(name: string): string { + return name.toLowerCase().replace(/^mcp__acp__/, "") +} + +function truncate(str: string, max: number): string { + return str.length > max ? str.substring(0, max - 3) + "..." : str +} + +function markdownEscape(text: string): string { + let fence = "```" + for (const match of text.matchAll(/^`{3,}/gm)) { + while (match[0].length >= fence.length) fence += "`" + } + return fence + "\n" + text + (text.endsWith("\n") ? "" : "\n") + fence +} + +function textContent(text: string): ToolCallContent { + return { type: "content", content: { type: "text", text } } +} + +function diffContent(path: string, oldText: string | null, newText: string): ToolCallContent { + return { type: "diff", path, oldText, newText } +} + +function str(v: unknown): string { + return typeof v === "string" ? v : "" +} + +function num(v: unknown): number | undefined { + return typeof v === "number" ? v : undefined +} + +function getFilePath(input: Record): string { + return str(input.filePath ?? input.file_path ?? input.filepath ?? input.path) +} + +function getOldString(input: Record): string { + return str(input.oldString ?? input.old_string) +} + +function getNewString(input: Record): string { + return str(input.newString ?? input.new_string ?? input.content ?? input.new_content) +} + +function getCommand(input: Record): string { + return str(input.command ?? input.cmd) +} + +function getDescription(input: Record): string { + return str(input.description ?? input.desc) +} + +function getPattern(input: Record): string { + return str(input.pattern ?? input.filePattern ?? input.glob) +} + +function getQuery(input: Record): string { + return str(input.query ?? input.q) +} + +function getUrl(input: Record): string { + return str(input.url ?? input.uri) +} + +function getDiff(input: Record): string { + return str(input.diff ?? input.patch ?? input.unifiedDiff) +} + +import { resolve } from "path" + +function abs(p: string): string { + return p && !p.startsWith("/") ? resolve(p) : p +} + +export function toolCallFromPart(tool: string, input: Record): ToolCallInfo { + const name = normalize(tool) + + switch (name) { + case "bash": + case "shell": + case "terminal": { + const command = getCommand(input) + const description = getDescription(input) + const cwd = str(input.cwd ?? input.workdir ?? input.workingDir ?? input.directory) + const result = ParseCommand.format(command, description, cwd) + return { + title: result.title, + kind: result.kind, + content: [], + locations: result.locations, + rawInput: input, + } + } + + case "bashoutput": { + return { + title: "Tail Logs", + kind: "execute", + content: [], + locations: [], + rawInput: input, + } + } + + case "read": + case "view": { + const filePath = getFilePath(input) + const offset = num(input.offset) ?? num(input.line) ?? 0 + const limit = num(input.limit) ?? 0 + let suffix = "" + if (limit) { + suffix = ` (${offset + 1} - ${offset + limit})` + } else if (offset) { + suffix = ` (from line ${offset + 1})` + } + return { + title: filePath ? `Read ${filePath}${suffix}` : "Read File", + kind: "read", + content: [], + locations: filePath ? [{ path: abs(filePath), ...(offset ? { line: offset + 1 } : {}) }] : [], + rawInput: input, + } + } + + case "list": + case "ls": { + const path = str(input.path) + return { + title: path ? `List \`${path}\`` : "List directory", + kind: "read", + content: [], + locations: path ? [{ path: abs(path) }] : [], + rawInput: input, + } + } + + case "edit": + case "str_replace": { + const filePath = getFilePath(input) + const oldString = getOldString(input) + const newString = getNewString(input) + return { + title: filePath ? `Edit \`${filePath}\`` : "Edit", + kind: "edit", + content: filePath ? [diffContent(abs(filePath), oldString, newString)] : [], + locations: filePath ? [{ path: abs(filePath) }] : [], + rawInput: input, + } + } + + case "patch": { + const filePath = getFilePath(input) + const patchText = getDiff(input) + return { + title: filePath ? `Patch \`${filePath}\`` : "Patch", + kind: "edit", + content: patchText ? [textContent(patchText)] : [], + locations: filePath ? [{ path: abs(filePath) }] : [], + rawInput: input, + } + } + + case "write": + case "create": { + const filePath = getFilePath(input) + const content = getNewString(input) + return { + title: filePath ? `Write ${filePath}` : "Write", + kind: "edit", + content: filePath ? [diffContent(abs(filePath), null, content)] : [], + locations: filePath ? [{ path: abs(filePath) }] : [], + rawInput: input, + } + } + + case "glob": + case "find": { + const path = str(input.path) + const pattern = getPattern(input) + let label = "Find" + if (path) label += ` \`${path}\`` + if (pattern) label += ` \`${pattern}\`` + return { + title: label, + kind: "search", + content: [], + locations: path ? [{ path: abs(path) }] : [], + rawInput: input, + } + } + + case "grep": + case "search": { + const pattern = getPattern(input) + const path = str(input.path) + let label = "grep" + if (pattern) label += ` "${truncate(pattern, 30)}"` + if (path) label += ` ${path}` + return { + title: label, + kind: "search", + content: [], + locations: path ? [{ path: abs(path) }] : [], + rawInput: input, + } + } + + case "webfetch": + case "fetch": { + const url = getUrl(input) + const prompt = str(input.prompt) + return { + title: url ? `Fetch ${truncate(url, 40)}` : "Fetch", + kind: "fetch", + content: prompt ? [textContent(prompt)] : [], + locations: [], + rawInput: input, + } + } + + case "websearch": { + const query = getQuery(input) + return { + title: query ? `"${truncate(query, 40)}"` : "Search", + kind: "fetch", + content: [], + locations: [], + rawInput: input, + } + } + + case "task": { + const description = getDescription(input) + const prompt = str(input.prompt) + return { + title: description || "Task", + kind: "think", + content: prompt ? [textContent(prompt)] : [], + locations: [], + rawInput: input, + } + } + + case "todowrite": + case "todoread": { + return { + title: "Update TODOs", + kind: "think", + content: [], + locations: [], + rawInput: input, + } + } + + case "plan_exit": { + return { + title: "Exit Plan Mode", + kind: "switch_mode", + content: [], + locations: [], + rawInput: input, + } + } + + case "plan_enter": { + return { + title: "Enter Plan Mode", + kind: "switch_mode", + content: [], + locations: [], + rawInput: input, + } + } + + case "apply_patch": { + const filePath = getFilePath(input) + const patchText = getDiff(input) + return { + title: filePath ? `Apply Patch \`${filePath}\`` : "Apply Patch", + kind: "edit", + content: patchText ? [textContent(patchText)] : [], + locations: filePath ? [{ path: abs(filePath) }] : [], + rawInput: input, + } + } + + case "multiedit": { + return { + title: "Multi Edit", + kind: "edit", + content: [], + locations: [], + rawInput: input, + } + } + + case "batch": { + return { + title: "Batch", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "skill": { + const name = str(input.name) + return { + title: name ? `Skill: ${name}` : "Skill", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "question": { + const question = getQuery(input) || str(input.question) + return { + title: question ? truncate(question, 40) : "Question", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "lsp": { + return { + title: "LSP", + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + + case "codesearch": { + const query = getQuery(input) + return { + title: query ? `Search: ${truncate(query, 30)}` : "Code Search", + kind: "search", + content: [], + locations: [], + rawInput: input, + } + } + + default: { + const description = getDescription(input) + const command = getCommand(input) + const title = description || command || tool + return { + title: truncate(title, 50), + kind: "other", + content: [], + locations: [], + rawInput: input, + } + } + } +} + +export function toolResultFromPart( + tool: string, + input: Record, + output: string, + isError: boolean, +): ToolResultInfo { + const name = normalize(tool) + const displayText = isError ? markdownEscape(output) : output + const content: ToolCallContent[] = [textContent(displayText)] + + switch (name) { + case "bash": + case "shell": + case "terminal": { + return { + content, + rawOutput: isError ? { stderr: output } : { stdout: output }, + } + } + + case "edit": + case "str_replace": { + const filePath = getFilePath(input) + const oldString = getOldString(input) + const newString = getNewString(input) + if (filePath && !isError) { + content.push(diffContent(abs(filePath), oldString, newString)) + } + return { + content, + rawOutput: { stdout: output }, + } + } + + case "patch": + case "apply_patch": { + const filePath = getFilePath(input) + const patchText = getDiff(input) + if (filePath && patchText && !isError) { + content.push(textContent(patchText)) + } + return { + content, + rawOutput: { stdout: output }, + } + } + + case "write": + case "create": { + const filePath = getFilePath(input) + const fileContent = getNewString(input) + if (filePath && !isError) { + content.push(diffContent(abs(filePath), null, fileContent)) + } + return { + content, + rawOutput: { stdout: output }, + } + } + + default: { + return { + content, + rawOutput: isError ? { stderr: output } : { stdout: output }, + } + } + } +} diff --git a/packages/opencode/test/acp/parse-command.test.ts b/packages/opencode/test/acp/parse-command.test.ts new file mode 100644 index 000000000000..782cf0fac528 --- /dev/null +++ b/packages/opencode/test/acp/parse-command.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "bun:test" +import { ParseCommand } from "../../src/acp/parse-command" + +describe("ParseCommand", () => { + describe("format", () => { + it("uses description as title when provided", () => { + const result = ParseCommand.format("ls", "List files in current directory", "/home/user") + expect(result.title).toBe("List files in current directory") + expect(result.kind).toBe("other") + }) + + it("falls back to command when no description", () => { + const result = ParseCommand.format("ls -la", "", "/home/user") + expect(result.title).toBe("ls -la") + }) + + it("includes cwd in locations", () => { + const result = ParseCommand.format("ls", "List files", "/home/user") + expect(result.locations).toEqual([{ path: "/home/user" }]) + }) + + it("handles empty cwd", () => { + const result = ParseCommand.format("ls", "List files", "") + expect(result.locations).toEqual([]) + }) + + it("sets terminalOutput to true", () => { + const result = ParseCommand.format("npm install", "Install dependencies", "/home/user") + expect(result.terminalOutput).toBe(true) + }) + }) +}) From 12edc9ef2767c63bed13a5bc6e1abaaeddfab6f3 Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Wed, 22 Apr 2026 13:31:22 +0300 Subject: [PATCH 3/7] test(acp): add comprehensive tests for tool call formatting and spec alignment --- .../opencode/test/acp/tool-format.test.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 packages/opencode/test/acp/tool-format.test.ts diff --git a/packages/opencode/test/acp/tool-format.test.ts b/packages/opencode/test/acp/tool-format.test.ts new file mode 100644 index 000000000000..c1c3b0d3b9fc --- /dev/null +++ b/packages/opencode/test/acp/tool-format.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "bun:test" +import { toolCallFromPart, toolResultFromPart } from "../../src/acp/tool-format" + +describe("toolCallFromPart", () => { + describe("bash/shell/terminal", () => { + it("formats bash command with description", () => { + const result = toolCallFromPart("bash", { command: "ls -la", description: "List files", cwd: "/home" }) + expect(result.title).toBe("List files") + expect(result.kind).toBe("other") + expect(result.locations).toEqual([{ path: "/home" }]) + expect(result.rawInput).toEqual({ command: "ls -la", description: "List files", cwd: "/home" }) + }) + + it("falls back to command when no description", () => { + const result = toolCallFromPart("shell", { command: "npm install" }) + expect(result.title).toBe("npm install") + }) + + it("normalizes mcp__acp__ prefix", () => { + const result = toolCallFromPart("mcp__acp__bash", { command: "echo hi" }) + expect(result.title).toBe("echo hi") + }) + }) + + describe("bashoutput", () => { + it("returns Tail Logs title", () => { + const result = toolCallFromPart("bashoutput", {}) + expect(result.title).toBe("Tail Logs") + expect(result.kind).toBe("execute") + }) + }) + + describe("read/view", () => { + it("formats read with file path", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts" }) + expect(result.title).toBe("Read /src/index.ts") + expect(result.kind).toBe("read") + expect(result.locations).toEqual([{ path: "/src/index.ts" }]) + }) + + it("uses 1-based line numbers when offset is provided", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) + expect(result.locations).toEqual([{ path: "/src/index.ts", line: 11 }]) + }) + + it("omits line key when offset is zero", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 0 }) + expect(result.locations).toEqual([{ path: "/src/index.ts" }]) + }) + + it("includes line range suffix", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10, limit: 20 }) + expect(result.title).toBe("Read /src/index.ts (11 - 30)") + }) + + it("includes from-line suffix", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) + expect(result.title).toBe("Read /src/index.ts (from line 11)") + }) + + it("falls back when no file path", () => { + const result = toolCallFromPart("read", {}) + expect(result.title).toBe("Read File") + expect(result.locations).toEqual([]) + }) + }) + + describe("edit/str_replace", () => { + it("formats edit with diff content", () => { + const result = toolCallFromPart("edit", { + filePath: "/src/app.ts", + oldString: "foo", + newString: "bar", + }) + expect(result.title).toBe("Edit `/src/app.ts`") + expect(result.kind).toBe("edit") + expect(result.content).toEqual([{ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }]) + expect(result.locations).toEqual([{ path: "/src/app.ts" }]) + }) + + it("handles missing file path", () => { + const result = toolCallFromPart("str_replace", { oldString: "a", newString: "b" }) + expect(result.title).toBe("Edit") + expect(result.content).toEqual([]) + }) + }) + + describe("write/create", () => { + it("formats write with diff content (null oldText)", () => { + const result = toolCallFromPart("write", { filePath: "/new.ts", content: "hello" }) + expect(result.title).toBe("Write /new.ts") + expect(result.kind).toBe("edit") + expect(result.content).toEqual([{ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }]) + }) + }) + + describe("glob/find", () => { + it("formats glob with path and pattern", () => { + const result = toolCallFromPart("glob", { path: "/src", pattern: "*.ts" }) + expect(result.title).toBe("Find `/src` `*.ts`") + expect(result.kind).toBe("search") + }) + + it("absolutizes relative paths", () => { + const result = toolCallFromPart("glob", { path: "src", pattern: "*.ts" }) + expect(result.locations[0].path.startsWith("/")).toBe(true) + }) + + it("handles no path or pattern", () => { + const result = toolCallFromPart("find", {}) + expect(result.title).toBe("Find") + }) + }) + + describe("grep/search", () => { + it("formats grep with pattern and path", () => { + const result = toolCallFromPart("grep", { pattern: "TODO", path: "/src" }) + expect(result.title).toBe('grep "TODO" /src') + expect(result.kind).toBe("search") + }) + + it("truncates long patterns", () => { + const long = "a".repeat(50) + const result = toolCallFromPart("grep", { pattern: long }) + expect(result.title.length).toBeLessThanOrEqual(40) + }) + }) + + describe("webfetch/fetch", () => { + it("formats fetch with url", () => { + const result = toolCallFromPart("webfetch", { url: "https://example.com", prompt: "get title" }) + expect(result.title).toBe("Fetch https://example.com") + expect(result.kind).toBe("fetch") + expect(result.content).toHaveLength(1) + }) + }) + + describe("websearch", () => { + it("formats search with query", () => { + const result = toolCallFromPart("websearch", { query: "bun test" }) + expect(result.title).toBe('"bun test"') + expect(result.kind).toBe("fetch") + }) + }) + + describe("task", () => { + it("formats task with description", () => { + const result = toolCallFromPart("task", { description: "Research APIs", prompt: "find REST patterns" }) + expect(result.title).toBe("Research APIs") + expect(result.kind).toBe("think") + expect(result.content).toHaveLength(1) + }) + }) + + describe("plan mode", () => { + it("emits switch_mode kind for plan_enter", () => { + const result = toolCallFromPart("plan_enter", {}) + expect(result.title).toBe("Enter Plan Mode") + expect(result.kind).toBe("switch_mode") + }) + + it("emits switch_mode kind for plan_exit", () => { + const result = toolCallFromPart("plan_exit", {}) + expect(result.title).toBe("Exit Plan Mode") + expect(result.kind).toBe("switch_mode") + }) + }) + + describe("bash kind pinning", () => { + it("uses kind 'other' instead of spec 'execute' to avoid Zed blue run-box styling", () => { + const result = toolCallFromPart("bash", { command: "ls", description: "List files" }) + expect(result.kind).toBe("other") + }) + }) + + describe("list", () => { + it("uses read kind for directory listing", () => { + const result = toolCallFromPart("list", { path: "/src" }) + expect(result.kind).toBe("read") + }) + }) + + describe("default", () => { + it("falls back to tool name for unknown tools", () => { + const result = toolCallFromPart("unknownTool", {}) + expect(result.title).toBe("unknownTool") + expect(result.kind).toBe("other") + }) + + it("uses description if available", () => { + const result = toolCallFromPart("custom", { description: "Custom action" }) + expect(result.title).toBe("Custom action") + }) + }) +}) + +describe("toolResultFromPart", () => { + describe("bash/shell/terminal", () => { + it("returns stdout for success", () => { + const result = toolResultFromPart("bash", { command: "ls" }, "file1\nfile2", false) + expect(result.rawOutput).toEqual({ stdout: "file1\nfile2" }) + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ type: "content", content: { type: "text", text: "file1\nfile2" } }) + }) + + it("returns stderr for error", () => { + const result = toolResultFromPart("shell", { command: "bad" }, "command not found", true) + expect(result.rawOutput).toEqual({ stderr: "command not found" }) + }) + }) + + describe("edit/str_replace", () => { + it("includes diff content on success", () => { + const result = toolResultFromPart( + "edit", + { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, + "Applied edit", + false, + ) + expect(result.rawOutput).toEqual({ stdout: "Applied edit" }) + expect(result.content).toHaveLength(2) + expect(result.content[1]).toEqual({ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }) + }) + + it("skips diff content on error", () => { + const result = toolResultFromPart( + "edit", + { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, + "old_string not found", + true, + ) + expect(result.content).toHaveLength(1) + }) + }) + + describe("write/create", () => { + it("includes diff content with null oldText on success", () => { + const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Created", false) + expect(result.content).toHaveLength(2) + expect(result.content[1]).toEqual({ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }) + }) + + it("skips diff on error", () => { + const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Permission denied", true) + expect(result.content).toHaveLength(1) + }) + }) + + describe("patch/apply_patch", () => { + it("includes patch text content on success", () => { + const patch = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new" + const result = toolResultFromPart("patch", { filePath: "/file", diff: patch }, "Patched", false) + expect(result.content).toHaveLength(2) + expect(result.content[1]).toEqual({ type: "content", content: { type: "text", text: patch } }) + }) + + it("skips patch content on error", () => { + const result = toolResultFromPart("apply_patch", { filePath: "/file", diff: "bad" }, "Failed", true) + expect(result.content).toHaveLength(1) + }) + }) + + describe("default", () => { + it("returns stdout for success", () => { + const result = toolResultFromPart("unknown", {}, "some output", false) + expect(result.rawOutput).toEqual({ stdout: "some output" }) + expect(result.content).toHaveLength(1) + }) + + it("returns stderr for error", () => { + const result = toolResultFromPart("unknown", {}, "error msg", true) + expect(result.rawOutput).toEqual({ stderr: "error msg" }) + }) + + it("wraps error output in markdown fence", () => { + const result = toolResultFromPart("unknown", {}, "some error", true) + const text = (result.content[0] as any).content.text + expect(text).toContain("```") + expect(text).toContain("some error") + }) + }) +}) From 0d816926fcba004c39fd05ba5d1496429bffc5cd Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Thu, 23 Apr 2026 15:57:43 +0300 Subject: [PATCH 4/7] refactor(acp): drop parse-command module, consolidate tool-format helpers - Remove parse-command.ts in favor of logic consolidated in tool-format.ts - Rewrite tool-format helpers (toolCallFromPart, toolResultFromPart, permissionDisplayInfo, fenceWith) as the single source of truth for tool call formatting - Adapt agent.ts to dev's post-namespace-refactor layout (flat exports with `export * as ACP` self-reexport, ProviderID/ModelID branded types, Hash.fast, InstallationVersion, ConfigMCP.Info) - Drop listSessions `unstable_` prefix to match SDK method name - Guard sendUsageUpdate against missing providerID/modelID --- packages/opencode/src/acp/parse-command.ts | 21 - packages/opencode/src/acp/tool-format.ts | 551 +++++++++++++----- .../opencode/test/acp/parse-command.test.ts | 32 - .../opencode/test/acp/tool-format.test.ts | 522 +++++++++++++++-- 4 files changed, 890 insertions(+), 236 deletions(-) delete mode 100644 packages/opencode/src/acp/parse-command.ts delete mode 100644 packages/opencode/test/acp/parse-command.test.ts diff --git a/packages/opencode/src/acp/parse-command.ts b/packages/opencode/src/acp/parse-command.ts deleted file mode 100644 index 433cd09d9570..000000000000 --- a/packages/opencode/src/acp/parse-command.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ToolKind } from "@agentclientprotocol/sdk" - -export namespace ParseCommand { - export interface Result { - kind: ToolKind - title: string - locations: { path: string }[] - terminalOutput: boolean - } - - export function format(command: string, description: string, cwd: string): Result { - const title = description || command || "Terminal" - - return { - kind: "other", - title, - locations: cwd ? [{ path: cwd }] : [], - terminalOutput: true, - } - } -} diff --git a/packages/opencode/src/acp/tool-format.ts b/packages/opencode/src/acp/tool-format.ts index 5a89e569c40c..34df68ffc7fa 100644 --- a/packages/opencode/src/acp/tool-format.ts +++ b/packages/opencode/src/acp/tool-format.ts @@ -1,5 +1,5 @@ +import { isAbsolute, resolve } from "path" import type { ToolCallContent, ToolKind } from "@agentclientprotocol/sdk" -import { ParseCommand } from "./parse-command" export interface ToolCallInfo { title: string @@ -12,9 +12,17 @@ export interface ToolCallInfo { export interface ToolResultInfo { content: ToolCallContent[] rawOutput: unknown - title?: string } +type ToolResultAttachment = { mime: string; url: string } & Record + +interface ToolResultOptions { + metadata?: unknown + attachments?: ToolResultAttachment[] +} + +const FENCE_RE = /^`{3,}/gm + function normalize(name: string): string { return name.toLowerCase().replace(/^mcp__acp__/, "") } @@ -23,12 +31,14 @@ function truncate(str: string, max: number): string { return str.length > max ? str.substring(0, max - 3) + "..." : str } -function markdownEscape(text: string): string { +export function fenceWith(text: string, lang: string): string { + if (!text) return text let fence = "```" - for (const match of text.matchAll(/^`{3,}/gm)) { + for (const match of text.matchAll(FENCE_RE)) { while (match[0].length >= fence.length) fence += "`" } - return fence + "\n" + text + (text.endsWith("\n") ? "" : "\n") + fence + const trimmed = text.replace(/\n+$/, "") + return `${fence}${lang}\n${trimmed}\n${fence}` } function textContent(text: string): ToolCallContent { @@ -39,161 +49,306 @@ function diffContent(path: string, oldText: string | null, newText: string): Too return { type: "diff", path, oldText, newText } } -function str(v: unknown): string { - return typeof v === "string" ? v : "" +function imageContents(attachments: ToolResultAttachment[] | undefined): ToolCallContent[] { + return (attachments ?? []).flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content", + content: { + type: "image", + mimeType: mime, + data, + }, + }, + ] + }) } -function num(v: unknown): number | undefined { - return typeof v === "number" ? v : undefined +function withImages(content: ToolCallContent[], options?: ToolResultOptions): ToolCallContent[] { + const images = imageContents(options?.attachments) + if (!images.length) return content + return [...content, ...images] } -function getFilePath(input: Record): string { - return str(input.filePath ?? input.file_path ?? input.filepath ?? input.path) +function rawOutputWithOptions(rawOutput: unknown, options?: ToolResultOptions): unknown { + if (options?.metadata === undefined && !options?.attachments?.length) return rawOutput + return { + ...((rawOutput !== null && typeof rawOutput === "object" && !Array.isArray(rawOutput) + ? rawOutput + : { value: rawOutput }) as Record), + ...(options.metadata !== undefined ? { metadata: options.metadata } : {}), + ...(options.attachments?.length ? { attachments: options.attachments } : {}), + } } -function getOldString(input: Record): string { - return str(input.oldString ?? input.old_string) +function str(v: unknown): string { + return typeof v === "string" ? v : "" } -function getNewString(input: Record): string { - return str(input.newString ?? input.new_string ?? input.content ?? input.new_content) +type ReadTruncation = + | { kind: "end"; total: number } + | { kind: "more"; from: number; to: number; total: number; next: number } + | { kind: "cut"; from: number; to: number; maxBytes: string; next: number } + | { kind: "dir_partial"; shown: number; total: number; next: number } + | { kind: "dir_full"; total: number } + +interface ParsedReadOutput { + path: string + type: "file" | "directory" + content: string + truncation?: ReadTruncation + systemReminder?: string } -function getCommand(input: Record): string { - return str(input.command ?? input.cmd) +const FILE_FOOTER_END = /\n\n\(End of file - total (\d+) lines\)$/ +const FILE_FOOTER_MORE = /\n\n\(Showing lines (\d+)-(\d+) of (\d+)\. Use offset=(\d+) to continue\.\)$/ +const FILE_FOOTER_CUT = /\n\n\(Output capped at (.+?)\. Showing lines (\d+)-(\d+)\. Use offset=(\d+) to continue\.\)$/ +const DIR_FOOTER_PARTIAL = /\n\(Showing (\d+) of (\d+) entries\. Use 'offset' parameter to read beyond entry (\d+)\)$/ +const DIR_FOOTER_FULL = /\n\((\d+) entries\)$/ +const SYSTEM_REMINDER_RE = /^\n\n\n([\s\S]*)\n<\/system-reminder>$/ + +interface ParsedSkillOutput { + name: string + markdown: string + baseDir: string + files: string[] } -function getDescription(input: Record): string { - return str(input.description ?? input.desc) +interface ParsedTaskOutput { + taskId: string + result: string } -function getPattern(input: Record): string { - return str(input.pattern ?? input.filePattern ?? input.glob) +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } -function getQuery(input: Record): string { - return str(input.query ?? input.q) +export function parseSkillOutput(output: string): ParsedSkillOutput | null { + try { + const open = output.match(/^\n/) + if (!open) return null + const name = open[1] + const close = "\n" + if (!output.endsWith(close)) return null + const body = output.slice(open[0].length, output.length - close.length) + + const headerRe = new RegExp(`^# Skill: ${escapeRegExp(name)}\\n\\n`) + const header = body.match(headerRe) + if (!header) return null + const afterHeader = body.slice(header[0].length) + + const skillFilesOpen = "\n\n\n" + const skillFilesClose = "\n" + const openIdx = afterHeader.lastIndexOf(skillFilesOpen) + if (openIdx < 0) return null + if (!afterHeader.endsWith(skillFilesClose)) return null + const preFiles = afterHeader.slice(0, openIdx) + const filesBlock = afterHeader.slice(openIdx + skillFilesOpen.length, afterHeader.length - skillFilesClose.length) + + const preambleRe = + /\n\nBase directory for this skill: (\S+)\nRelative paths in this skill \(e\.g\., scripts\/, reference\/\) are relative to this base directory\.\nNote: file list is sampled\.$/ + const preamble = preFiles.match(preambleRe) + if (!preamble) return null + const markdown = preFiles.slice(0, preFiles.length - preamble[0].length) + const baseDir = preamble[1] + + let files: string[] = [] + if (filesBlock !== "") { + files = filesBlock.split("\n").map((line) => { + const m = line.match(/^(.*)<\/file>$/) + if (!m) throw new Error("bad file line") + return m[1] + }) + } + + return { name, markdown, baseDir, files } + } catch { + return null + } } -function getUrl(input: Record): string { - return str(input.url ?? input.uri) +export function parseTaskOutput(output: string): ParsedTaskOutput | null { + try { + const m = output.match( + /^task_id: (\S+) \(for resuming to continue this task if needed\)\n\n\n([\s\S]*)\n<\/task_result>$/, + ) + if (!m) return null + return { taskId: m[1], result: m[2] } + } catch { + return null + } } -function getDiff(input: Record): string { - return str(input.diff ?? input.patch ?? input.unifiedDiff) +export function parseReadOutput(output: string): ParsedReadOutput | null { + try { + const envelope = output.match(/^([^\n]*)<\/path>\n(file|directory)<\/type>\n/) + if (!envelope) return null + const path = envelope[1] + const type = envelope[2] as "file" | "directory" + const afterEnvelope = output.slice(envelope[0].length) + + const openTag = type === "file" ? "\n" : "\n" + const closeTag = type === "file" ? "\n" : "\n" + if (!afterEnvelope.startsWith(openTag)) return null + const bodyStart = openTag.length + const closeIdx = afterEnvelope.lastIndexOf(closeTag) + if (closeIdx < bodyStart) return null + + const body = afterEnvelope.slice(bodyStart, closeIdx) + const afterClose = afterEnvelope.slice(closeIdx + closeTag.length) + + let systemReminder: string | undefined + if (afterClose.length > 0) { + const sr = afterClose.match(SYSTEM_REMINDER_RE) + if (!sr) return null + systemReminder = sr[1] + } + + let truncation: ReadTruncation | undefined + let content = body + if (type === "file") { + const end = body.match(FILE_FOOTER_END) + const more = body.match(FILE_FOOTER_MORE) + const cut = body.match(FILE_FOOTER_CUT) + if (end) { + content = body.slice(0, body.length - end[0].length) + truncation = { kind: "end", total: Number(end[1]) } + } else if (more) { + content = body.slice(0, body.length - more[0].length) + truncation = { + kind: "more", + from: Number(more[1]), + to: Number(more[2]), + total: Number(more[3]), + next: Number(more[4]), + } + } else if (cut) { + content = body.slice(0, body.length - cut[0].length) + truncation = { + kind: "cut", + maxBytes: cut[1], + from: Number(cut[2]), + to: Number(cut[3]), + next: Number(cut[4]), + } + } + } else { + const partial = body.match(DIR_FOOTER_PARTIAL) + const full = body.match(DIR_FOOTER_FULL) + if (partial) { + content = body.slice(0, body.length - partial[0].length) + truncation = { + kind: "dir_partial", + shown: Number(partial[1]), + total: Number(partial[2]), + next: Number(partial[3]), + } + } else if (full) { + content = body.slice(0, body.length - full[0].length) + truncation = { kind: "dir_full", total: Number(full[1]) } + } + } + + const result: ParsedReadOutput = { path, type, content } + if (truncation) result.truncation = truncation + if (systemReminder !== undefined) result.systemReminder = systemReminder + return result + } catch { + return null + } } -import { resolve } from "path" +function num(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) ? v : undefined +} -function abs(p: string): string { - return p && !p.startsWith("/") ? resolve(p) : p +function abs(p: string, cwd: string): string { + return p && !isAbsolute(p) ? resolve(cwd, p) : p } -export function toolCallFromPart(tool: string, input: Record): ToolCallInfo { +export function toolCallFromPart(tool: string, input: Record, cwd: string): ToolCallInfo { const name = normalize(tool) switch (name) { - case "bash": - case "shell": - case "terminal": { - const command = getCommand(input) - const description = getDescription(input) - const cwd = str(input.cwd ?? input.workdir ?? input.workingDir ?? input.directory) - const result = ParseCommand.format(command, description, cwd) - return { - title: result.title, - kind: result.kind, - content: [], - locations: result.locations, - rawInput: input, - } - } - - case "bashoutput": { + case "bash": { + const command = str(input.command) + const description = str(input.description) + const workdir = str(input.workdir) + const resolvedWorkdir = workdir ? abs(workdir, cwd) : cwd return { - title: "Tail Logs", - kind: "execute", + title: description || command || "Terminal", + kind: "other", content: [], - locations: [], - rawInput: input, + locations: [{ path: resolvedWorkdir }], + rawInput: workdir ? input : { ...input, cwd }, } } - case "read": - case "view": { - const filePath = getFilePath(input) - const offset = num(input.offset) ?? num(input.line) ?? 0 + case "read": { + const filePath = str(input.filePath) + const offset = num(input.offset) ?? 1 const limit = num(input.limit) ?? 0 + const hasMeaningfulOffset = offset > 1 + const rangeStart = Math.max(offset, 1) let suffix = "" if (limit) { - suffix = ` (${offset + 1} - ${offset + limit})` - } else if (offset) { - suffix = ` (from line ${offset + 1})` + suffix = ` (${rangeStart} - ${rangeStart + limit - 1})` + } else if (hasMeaningfulOffset) { + suffix = ` (from line ${offset})` } return { title: filePath ? `Read ${filePath}${suffix}` : "Read File", kind: "read", content: [], - locations: filePath ? [{ path: abs(filePath), ...(offset ? { line: offset + 1 } : {}) }] : [], + locations: filePath ? [{ path: abs(filePath, cwd), ...(hasMeaningfulOffset ? { line: offset } : {}) }] : [], rawInput: input, } } - case "list": - case "ls": { + case "list": { const path = str(input.path) return { title: path ? `List \`${path}\`` : "List directory", kind: "read", content: [], - locations: path ? [{ path: abs(path) }] : [], + locations: path ? [{ path: abs(path, cwd) }] : [], rawInput: input, } } - case "edit": - case "str_replace": { - const filePath = getFilePath(input) - const oldString = getOldString(input) - const newString = getNewString(input) + case "edit": { + const filePath = str(input.filePath) + const oldString = str(input.oldString) + const newString = str(input.newString) return { title: filePath ? `Edit \`${filePath}\`` : "Edit", kind: "edit", - content: filePath ? [diffContent(abs(filePath), oldString, newString)] : [], - locations: filePath ? [{ path: abs(filePath) }] : [], + content: filePath ? [diffContent(abs(filePath, cwd), oldString, newString)] : [], + locations: filePath ? [{ path: abs(filePath, cwd) }] : [], rawInput: input, } } - case "patch": { - const filePath = getFilePath(input) - const patchText = getDiff(input) - return { - title: filePath ? `Patch \`${filePath}\`` : "Patch", - kind: "edit", - content: patchText ? [textContent(patchText)] : [], - locations: filePath ? [{ path: abs(filePath) }] : [], - rawInput: input, - } - } - - case "write": - case "create": { - const filePath = getFilePath(input) - const content = getNewString(input) + case "write": { + const filePath = str(input.filePath) + const content = str(input.content) return { title: filePath ? `Write ${filePath}` : "Write", kind: "edit", - content: filePath ? [diffContent(abs(filePath), null, content)] : [], - locations: filePath ? [{ path: abs(filePath) }] : [], + content: filePath ? [diffContent(abs(filePath, cwd), null, content)] : [], + locations: filePath ? [{ path: abs(filePath, cwd) }] : [], rawInput: input, } } - case "glob": - case "find": { + case "glob": { const path = str(input.path) - const pattern = getPattern(input) + const pattern = str(input.pattern) let label = "Find" if (path) label += ` \`${path}\`` if (pattern) label += ` \`${pattern}\`` @@ -201,14 +356,13 @@ export function toolCallFromPart(tool: string, input: Record): title: label, kind: "search", content: [], - locations: path ? [{ path: abs(path) }] : [], + locations: path ? [{ path: abs(path, cwd) }] : [], rawInput: input, } } - case "grep": - case "search": { - const pattern = getPattern(input) + case "grep": { + const pattern = str(input.pattern) const path = str(input.path) let label = "grep" if (pattern) label += ` "${truncate(pattern, 30)}"` @@ -217,14 +371,13 @@ export function toolCallFromPart(tool: string, input: Record): title: label, kind: "search", content: [], - locations: path ? [{ path: abs(path) }] : [], + locations: path ? [{ path: abs(path, cwd) }] : [], rawInput: input, } } - case "webfetch": - case "fetch": { - const url = getUrl(input) + case "webfetch": { + const url = str(input.url) const prompt = str(input.prompt) return { title: url ? `Fetch ${truncate(url, 40)}` : "Fetch", @@ -236,7 +389,7 @@ export function toolCallFromPart(tool: string, input: Record): } case "websearch": { - const query = getQuery(input) + const query = str(input.query) return { title: query ? `"${truncate(query, 40)}"` : "Search", kind: "fetch", @@ -247,7 +400,7 @@ export function toolCallFromPart(tool: string, input: Record): } case "task": { - const description = getDescription(input) + const description = str(input.description) const prompt = str(input.prompt) return { title: description || "Task", @@ -290,13 +443,12 @@ export function toolCallFromPart(tool: string, input: Record): } case "apply_patch": { - const filePath = getFilePath(input) - const patchText = getDiff(input) + const patchText = str(input.patchText) return { - title: filePath ? `Apply Patch \`${filePath}\`` : "Apply Patch", + title: "Apply Patch", kind: "edit", content: patchText ? [textContent(patchText)] : [], - locations: filePath ? [{ path: abs(filePath) }] : [], + locations: [], rawInput: input, } } @@ -322,9 +474,9 @@ export function toolCallFromPart(tool: string, input: Record): } case "skill": { - const name = str(input.name) + const skillName = str(input.name) return { - title: name ? `Skill: ${name}` : "Skill", + title: skillName ? `Skill: ${skillName}` : "Skill", kind: "other", content: [], locations: [], @@ -333,7 +485,7 @@ export function toolCallFromPart(tool: string, input: Record): } case "question": { - const question = getQuery(input) || str(input.question) + const question = str(input.question) || str(input.query) return { title: question ? truncate(question, 40) : "Question", kind: "other", @@ -354,7 +506,7 @@ export function toolCallFromPart(tool: string, input: Record): } case "codesearch": { - const query = getQuery(input) + const query = str(input.query) return { title: query ? `Search: ${truncate(query, 30)}` : "Code Search", kind: "search", @@ -365,8 +517,8 @@ export function toolCallFromPart(tool: string, input: Record): } default: { - const description = getDescription(input) - const command = getCommand(input) + const description = str(input.description) + const command = str(input.command) const title = description || command || tool return { title: truncate(title, 50), @@ -384,65 +536,180 @@ export function toolResultFromPart( input: Record, output: string, isError: boolean, + cwd: string, + options?: ToolResultOptions, ): ToolResultInfo { const name = normalize(tool) - const displayText = isError ? markdownEscape(output) : output - const content: ToolCallContent[] = [textContent(displayText)] + + if (name === "bash") { + const text = fenceWith(output, isError ? "" : "sh") + return { + content: withImages([textContent(text)], options), + rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), + } + } + + const content: ToolCallContent[] = [textContent(fenceWith(output, ""))] switch (name) { - case "bash": - case "shell": - case "terminal": { + case "read": + case "list": { + if (isError) { + return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } + } + const parsed = parseReadOutput(output) return { - content, - rawOutput: isError ? { stderr: output } : { stdout: output }, + content: withImages([], options), + rawOutput: rawOutputWithOptions(parsed ?? { stdout: output }, options), } } - case "edit": - case "str_replace": { - const filePath = getFilePath(input) - const oldString = getOldString(input) - const newString = getNewString(input) + case "skill": { + if (isError) { + return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } + } + const parsed = parseSkillOutput(output) + return { + content: withImages([], options), + rawOutput: rawOutputWithOptions(parsed ?? { stdout: output }, options), + } + } + + case "task": { + if (isError) { + return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } + } + const parsed = parseTaskOutput(output) + return { + content: withImages([], options), + rawOutput: rawOutputWithOptions(parsed ?? { stdout: output }, options), + } + } + + case "edit": { + const filePath = str(input.filePath) + const oldString = str(input.oldString) + const newString = str(input.newString) if (filePath && !isError) { - content.push(diffContent(abs(filePath), oldString, newString)) + content.push(diffContent(abs(filePath, cwd), oldString, newString)) } return { - content, - rawOutput: { stdout: output }, + content: withImages(content, options), + rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), } } - case "patch": case "apply_patch": { - const filePath = getFilePath(input) - const patchText = getDiff(input) - if (filePath && patchText && !isError) { - content.push(textContent(patchText)) + const patchText = str(input.patchText) + if (isError) { + return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } } + const successContent: ToolCallContent[] = patchText ? [textContent(fenceWith(patchText, "diff"))] : [] return { - content, - rawOutput: { stdout: output }, + content: withImages(successContent, options), + rawOutput: rawOutputWithOptions({ stdout: output }, options), } } - case "write": - case "create": { - const filePath = getFilePath(input) - const fileContent = getNewString(input) + case "write": { + const filePath = str(input.filePath) + const fileContent = str(input.content) if (filePath && !isError) { - content.push(diffContent(abs(filePath), null, fileContent)) + content.push(diffContent(abs(filePath, cwd), null, fileContent)) } return { - content, - rawOutput: { stdout: output }, + content: withImages(content, options), + rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), } } default: { return { - content, - rawOutput: isError ? { stderr: output } : { stdout: output }, + content: withImages(content, options), + rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), + } + } + } +} + +export function permissionDisplayInfo( + permission: string, + metadata: Record, + cwd: string, +): ToolCallInfo { + const name = normalize(permission) + switch (name) { + case "edit": + case "write": + case "apply_patch": { + const filepath = str(metadata.filepath) + const diff = str(metadata.diff) + return { + title: filepath ? `Edit ${filepath}` : "Edit", + kind: "edit", + content: diff ? [textContent(diff)] : [], + locations: filepath ? [{ path: abs(filepath, cwd) }] : [], + rawInput: metadata, + } + } + case "bash": { + const command = str(metadata.command) + const description = str(metadata.description) + return { + title: description || command || "Terminal", + kind: "execute", + content: [], + locations: [], + rawInput: metadata, + } + } + case "webfetch": { + const url = str(metadata.url) + return { + title: url ? `Fetch ${truncate(url, 40)}` : "Fetch", + kind: "fetch", + content: [], + locations: [], + rawInput: metadata, + } + } + case "websearch": { + const query = str(metadata.query) + return { + title: query ? `"${truncate(query, 40)}"` : "Search", + kind: "fetch", + content: [], + locations: [], + rawInput: metadata, + } + } + case "task": { + const description = str(metadata.description) + return { + title: description || "Task", + kind: "think", + content: [], + locations: [], + rawInput: metadata, + } + } + case "skill": { + const skillName = str(metadata.name) + return { + title: skillName ? `Skill: ${skillName}` : "Skill", + kind: "other", + content: [], + locations: [], + rawInput: metadata, + } + } + default: { + return { + title: permission || "Permission", + kind: "other", + content: [], + locations: [], + rawInput: metadata, } } } diff --git a/packages/opencode/test/acp/parse-command.test.ts b/packages/opencode/test/acp/parse-command.test.ts deleted file mode 100644 index 782cf0fac528..000000000000 --- a/packages/opencode/test/acp/parse-command.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { ParseCommand } from "../../src/acp/parse-command" - -describe("ParseCommand", () => { - describe("format", () => { - it("uses description as title when provided", () => { - const result = ParseCommand.format("ls", "List files in current directory", "/home/user") - expect(result.title).toBe("List files in current directory") - expect(result.kind).toBe("other") - }) - - it("falls back to command when no description", () => { - const result = ParseCommand.format("ls -la", "", "/home/user") - expect(result.title).toBe("ls -la") - }) - - it("includes cwd in locations", () => { - const result = ParseCommand.format("ls", "List files", "/home/user") - expect(result.locations).toEqual([{ path: "/home/user" }]) - }) - - it("handles empty cwd", () => { - const result = ParseCommand.format("ls", "List files", "") - expect(result.locations).toEqual([]) - }) - - it("sets terminalOutput to true", () => { - const result = ParseCommand.format("npm install", "Install dependencies", "/home/user") - expect(result.terminalOutput).toBe(true) - }) - }) -}) diff --git a/packages/opencode/test/acp/tool-format.test.ts b/packages/opencode/test/acp/tool-format.test.ts index c1c3b0d3b9fc..8b495867b113 100644 --- a/packages/opencode/test/acp/tool-format.test.ts +++ b/packages/opencode/test/acp/tool-format.test.ts @@ -1,18 +1,34 @@ import { describe, expect, it } from "bun:test" -import { toolCallFromPart, toolResultFromPart } from "../../src/acp/tool-format" +import { isAbsolute, resolve } from "path" +import { + permissionDisplayInfo, + toolCallFromPart as _toolCallFromPart, + toolResultFromPart as _toolResultFromPart, +} from "../../src/acp/tool-format" + +const CWD = process.cwd() +const toolCallFromPart = (tool: string, input: Record, cwd: string = CWD) => + _toolCallFromPart(tool, input, cwd) +const toolResultFromPart = ( + tool: string, + input: Record, + output: string, + isError: boolean, + cwd: string = CWD, +) => _toolResultFromPart(tool, input, output, isError, cwd) describe("toolCallFromPart", () => { - describe("bash/shell/terminal", () => { - it("formats bash command with description", () => { - const result = toolCallFromPart("bash", { command: "ls -la", description: "List files", cwd: "/home" }) + describe("bash", () => { + it("formats bash command with description and workdir location", () => { + const result = toolCallFromPart("bash", { command: "ls -la", description: "List files", workdir: "/home" }) expect(result.title).toBe("List files") expect(result.kind).toBe("other") expect(result.locations).toEqual([{ path: "/home" }]) - expect(result.rawInput).toEqual({ command: "ls -la", description: "List files", cwd: "/home" }) + expect(result.rawInput).toEqual({ command: "ls -la", description: "List files", workdir: "/home" }) }) it("falls back to command when no description", () => { - const result = toolCallFromPart("shell", { command: "npm install" }) + const result = toolCallFromPart("bash", { command: "npm install" }) expect(result.title).toBe("npm install") }) @@ -20,17 +36,21 @@ describe("toolCallFromPart", () => { const result = toolCallFromPart("mcp__acp__bash", { command: "echo hi" }) expect(result.title).toBe("echo hi") }) - }) - describe("bashoutput", () => { - it("returns Tail Logs title", () => { - const result = toolCallFromPart("bashoutput", {}) - expect(result.title).toBe("Tail Logs") - expect(result.kind).toBe("execute") + it("falls back to session cwd in locations and rawInput when workdir is absent", () => { + const cwd = resolve("/workspace/project") + const result = toolCallFromPart("bash", { command: "ls" }, cwd) + expect(result.locations).toEqual([{ path: cwd }]) + expect(result.rawInput).toEqual({ command: "ls", cwd }) + }) + + it("does not inject cwd into rawInput when workdir is provided", () => { + const result = toolCallFromPart("bash", { command: "ls", workdir: "/opt" }, resolve("/workspace")) + expect(result.rawInput).toEqual({ command: "ls", workdir: "/opt" }) }) }) - describe("read/view", () => { + describe("read", () => { it("formats read with file path", () => { const result = toolCallFromPart("read", { filePath: "/src/index.ts" }) expect(result.title).toBe("Read /src/index.ts") @@ -38,9 +58,9 @@ describe("toolCallFromPart", () => { expect(result.locations).toEqual([{ path: "/src/index.ts" }]) }) - it("uses 1-based line numbers when offset is provided", () => { + it("uses 1-based line numbers when offset is provided (matches read tool contract)", () => { const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) - expect(result.locations).toEqual([{ path: "/src/index.ts", line: 11 }]) + expect(result.locations).toEqual([{ path: "/src/index.ts", line: 10 }]) }) it("omits line key when offset is zero", () => { @@ -48,14 +68,31 @@ describe("toolCallFromPart", () => { expect(result.locations).toEqual([{ path: "/src/index.ts" }]) }) + it("omits line key when offset is 1 (redundant with default)", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 1 }) + expect(result.locations).toEqual([{ path: "/src/index.ts" }]) + expect(result.title).toBe("Read /src/index.ts") + }) + it("includes line range suffix", () => { const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10, limit: 20 }) - expect(result.title).toBe("Read /src/index.ts (11 - 30)") + expect(result.title).toBe("Read /src/index.ts (10 - 29)") + }) + + it("clamps range start to 1 when only limit is given", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", limit: 20 }) + expect(result.title).toBe("Read /src/index.ts (1 - 20)") + }) + + it("ignores non-finite offset and limit values", () => { + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: NaN, limit: Infinity }) + expect(result.title).toBe("Read /src/index.ts") + expect(result.locations).toEqual([{ path: "/src/index.ts" }]) }) it("includes from-line suffix", () => { const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) - expect(result.title).toBe("Read /src/index.ts (from line 11)") + expect(result.title).toBe("Read /src/index.ts (from line 10)") }) it("falls back when no file path", () => { @@ -65,7 +102,7 @@ describe("toolCallFromPart", () => { }) }) - describe("edit/str_replace", () => { + describe("edit", () => { it("formats edit with diff content", () => { const result = toolCallFromPart("edit", { filePath: "/src/app.ts", @@ -79,13 +116,13 @@ describe("toolCallFromPart", () => { }) it("handles missing file path", () => { - const result = toolCallFromPart("str_replace", { oldString: "a", newString: "b" }) + const result = toolCallFromPart("edit", { oldString: "a", newString: "b" }) expect(result.title).toBe("Edit") expect(result.content).toEqual([]) }) }) - describe("write/create", () => { + describe("write", () => { it("formats write with diff content (null oldText)", () => { const result = toolCallFromPart("write", { filePath: "/new.ts", content: "hello" }) expect(result.title).toBe("Write /new.ts") @@ -94,7 +131,7 @@ describe("toolCallFromPart", () => { }) }) - describe("glob/find", () => { + describe("glob", () => { it("formats glob with path and pattern", () => { const result = toolCallFromPart("glob", { path: "/src", pattern: "*.ts" }) expect(result.title).toBe("Find `/src` `*.ts`") @@ -103,16 +140,16 @@ describe("toolCallFromPart", () => { it("absolutizes relative paths", () => { const result = toolCallFromPart("glob", { path: "src", pattern: "*.ts" }) - expect(result.locations[0].path.startsWith("/")).toBe(true) + expect(isAbsolute(result.locations[0].path)).toBe(true) }) it("handles no path or pattern", () => { - const result = toolCallFromPart("find", {}) + const result = toolCallFromPart("glob", {}) expect(result.title).toBe("Find") }) }) - describe("grep/search", () => { + describe("grep", () => { it("formats grep with pattern and path", () => { const result = toolCallFromPart("grep", { pattern: "TODO", path: "/src" }) expect(result.title).toBe('grep "TODO" /src') @@ -126,7 +163,7 @@ describe("toolCallFromPart", () => { }) }) - describe("webfetch/fetch", () => { + describe("webfetch", () => { it("formats fetch with url", () => { const result = toolCallFromPart("webfetch", { url: "https://example.com", prompt: "get title" }) expect(result.title).toBe("Fetch https://example.com") @@ -195,21 +232,24 @@ describe("toolCallFromPart", () => { }) describe("toolResultFromPart", () => { - describe("bash/shell/terminal", () => { - it("returns stdout for success", () => { + describe("bash", () => { + it("returns stdout for success wrapped in shell code fence", () => { const result = toolResultFromPart("bash", { command: "ls" }, "file1\nfile2", false) expect(result.rawOutput).toEqual({ stdout: "file1\nfile2" }) expect(result.content).toHaveLength(1) - expect(result.content[0]).toEqual({ type: "content", content: { type: "text", text: "file1\nfile2" } }) + expect(result.content[0]).toEqual({ + type: "content", + content: { type: "text", text: "```sh\nfile1\nfile2\n```" }, + }) }) it("returns stderr for error", () => { - const result = toolResultFromPart("shell", { command: "bad" }, "command not found", true) + const result = toolResultFromPart("bash", { command: "bad" }, "command not found", true) expect(result.rawOutput).toEqual({ stderr: "command not found" }) }) }) - describe("edit/str_replace", () => { + describe("edit", () => { it("includes diff content on success", () => { const result = toolResultFromPart( "edit", @@ -222,7 +262,7 @@ describe("toolResultFromPart", () => { expect(result.content[1]).toEqual({ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }) }) - it("skips diff content on error", () => { + it("skips diff content on error and returns stderr", () => { const result = toolResultFromPart( "edit", { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, @@ -230,33 +270,49 @@ describe("toolResultFromPart", () => { true, ) expect(result.content).toHaveLength(1) + expect(result.rawOutput).toEqual({ stderr: "old_string not found" }) }) }) - describe("write/create", () => { + describe("write", () => { it("includes diff content with null oldText on success", () => { const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Created", false) expect(result.content).toHaveLength(2) expect(result.content[1]).toEqual({ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }) }) - it("skips diff on error", () => { + it("skips diff on error and returns stderr", () => { const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Permission denied", true) expect(result.content).toHaveLength(1) + expect(result.rawOutput).toEqual({ stderr: "Permission denied" }) }) }) - describe("patch/apply_patch", () => { - it("includes patch text content on success", () => { - const patch = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new" - const result = toolResultFromPart("patch", { filePath: "/file", diff: patch }, "Patched", false) - expect(result.content).toHaveLength(2) - expect(result.content[1]).toEqual({ type: "content", content: { type: "text", text: patch } }) + describe("apply_patch", () => { + it("returns only a diff-fenced patch block on success (title=output dedupes body)", () => { + const patchText = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new" + const result = toolResultFromPart("apply_patch", { patchText }, "Patched", false) + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ + type: "content", + content: { type: "text", text: "```diff\n" + patchText + "\n```" }, + }) + expect(result.rawOutput).toEqual({ stdout: "Patched" }) }) - it("skips patch content on error", () => { - const result = toolResultFromPart("apply_patch", { filePath: "/file", diff: "bad" }, "Failed", true) + it("returns empty content when patchText is absent on success", () => { + const result = toolResultFromPart("apply_patch", {}, "Patched", false) + expect(result.content).toHaveLength(0) + expect(result.rawOutput).toEqual({ stdout: "Patched" }) + }) + + it("keeps fenced error text and returns stderr on error", () => { + const result = toolResultFromPart("apply_patch", { patchText: "bad" }, "Failed", true) expect(result.content).toHaveLength(1) + const text = (result.content[0] as any).content.text + expect(text).toMatch(/^```\n/) + expect(text).toContain("Failed") + expect(result.rawOutput).toEqual({ stderr: "Failed" }) }) }) @@ -280,3 +336,387 @@ describe("toolResultFromPart", () => { }) }) }) + +describe("apply_patch canonical field", () => { + it("reads patchText from call input and emits content block", () => { + const patchText = "--- a/foo\n+++ b/foo\n@@ -1 +1 @@\n-x\n+y" + const call = toolCallFromPart("apply_patch", { patchText }) + expect(call.title).toBe("Apply Patch") + expect(call.kind).toBe("edit") + expect(call.content).toEqual([{ type: "content", content: { type: "text", text: patchText } }]) + }) + + it("produces empty content when patchText is absent", () => { + const call = toolCallFromPart("apply_patch", {}) + expect(call.content).toEqual([]) + }) +}) + +describe("path resolution against cwd", () => { + it("resolves relative filePath against the passed cwd (not process.cwd)", () => { + const cwd = resolve("/workspace/project") + const result = toolCallFromPart("read", { filePath: "src/index.ts" }, cwd) + expect(result.locations).toEqual([{ path: resolve(cwd, "src/index.ts") }]) + }) + + it("leaves absolute paths untouched", () => { + const absolutePath = resolve("/abs/path.ts") + const result = toolCallFromPart("read", { filePath: absolutePath }, resolve("/workspace/project")) + expect(result.locations).toEqual([{ path: absolutePath }]) + }) + + it("resolves relative path in edit result content", () => { + const cwd = resolve("/workspace") + const result = toolResultFromPart( + "edit", + { filePath: "rel/file.ts", oldString: "a", newString: "b" }, + "ok", + false, + cwd, + ) + expect(result.content[1]).toEqual({ + type: "diff", + path: resolve(cwd, "rel/file.ts"), + oldText: "a", + newText: "b", + }) + }) + + it("resolves relative bash workdir against cwd", () => { + const cwd = resolve("/workspace") + const result = toolCallFromPart("bash", { command: "ls", workdir: "rel" }, cwd) + expect(result.locations).toEqual([{ path: resolve(cwd, "rel") }]) + }) +}) + +describe("non-bash tool output fencing", () => { + it("fences opencode-style MCP tool output (exa_web_search_exa, no mcp__ prefix) to prevent markdown injection", () => { + const output = "# Title\nsome result\n## Section\ncontent" + const result = toolResultFromPart("exa_web_search_exa", {}, output, false) + const text = (result.content[0] as any).content.text + expect(text).toMatch(/^```\n/) + expect(text).toContain(output) + expect(text).toMatch(/```$/) + expect(result.rawOutput).toEqual({ stdout: output }) + }) + + it("fences default-branch success output (grep/webfetch/todowrite/unknown) in a plain code block", () => { + for (const tool of ["grep", "webfetch", "todowrite", "codesearch", "unknownTool"]) { + const result = toolResultFromPart(tool, {}, "plain text", false) + const text = (result.content[0] as any).content.text + expect(text).toMatch(/^```\n/) + expect(text).toContain("plain text") + expect(text).toMatch(/\n```$/) + } + }) + + it("drops content on read/list success (title + locations already carry the UX) but preserves rawOutput", () => { + for (const tool of ["read", "list"]) { + const result = toolResultFromPart(tool, {}, "plain text", false) + expect(result.content).toEqual([]) + // unparseable wrapper → falls back to stdout + expect(result.rawOutput).toEqual({ stdout: "plain text" }) + } + }) + + it("fences read/list error output so the user sees why it failed", () => { + for (const tool of ["read", "list"]) { + const result = toolResultFromPart(tool, { filePath: "/missing" }, "ENOENT: no such file", true) + const text = (result.content[0] as any).content.text + expect(text).toMatch(/^```\n/) + expect(text).toContain("ENOENT: no such file") + expect(text).toMatch(/\n```$/) + expect(result.rawOutput).toEqual({ stderr: "ENOENT: no such file" }) + } + }) + + it("parses read file wrapper into structured rawOutput with end footer", () => { + const output = + "/src/a.ts\nfile\n\n1: line one\n2: line two\n\n(End of file - total 2 lines)\n" + const result = toolResultFromPart("read", { filePath: "/src/a.ts" }, output, false) + expect(result.content).toEqual([]) + expect(result.rawOutput).toEqual({ + path: "/src/a.ts", + type: "file", + content: "1: line one\n2: line two", + truncation: { kind: "end", total: 2 }, + }) + }) + + it("parses read file wrapper with 'more' truncation footer", () => { + const output = + "/src/big.ts\nfile\n\n1: a\n2: b\n\n(Showing lines 1-2 of 500. Use offset=3 to continue.)\n" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ + path: "/src/big.ts", + type: "file", + content: "1: a\n2: b", + truncation: { kind: "more", from: 1, to: 2, total: 500, next: 3 }, + }) + }) + + it("parses read file wrapper with 'cut' truncation footer", () => { + const output = + "/src/huge.bin\nfile\n\n1: a\n2: b\n\n(Output capped at 256KB. Showing lines 1-2. Use offset=3 to continue.)\n" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ + path: "/src/huge.bin", + type: "file", + content: "1: a\n2: b", + truncation: { kind: "cut", maxBytes: "256KB", from: 1, to: 2, next: 3 }, + }) + }) + + it("parses read directory wrapper with full footer", () => { + const output = + "/src\ndirectory\n\na.ts\nb.ts\n(2 entries)\n" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ + path: "/src", + type: "directory", + content: "a.ts\nb.ts", + truncation: { kind: "dir_full", total: 2 }, + }) + }) + + it("parses read directory wrapper with partial footer", () => { + const output = + "/src\ndirectory\n\na.ts\nb.ts\n(Showing 2 of 100 entries. Use 'offset' parameter to read beyond entry 2)\n" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ + path: "/src", + type: "directory", + content: "a.ts\nb.ts", + truncation: { kind: "dir_partial", shown: 2, total: 100, next: 2 }, + }) + }) + + it("parses read wrapper with trailing system-reminder", () => { + const output = + "/src/a.ts\nfile\n\n1: x\n\n(End of file - total 1 lines)\n\n\n\nfollow project conventions\n" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ + path: "/src/a.ts", + type: "file", + content: "1: x", + truncation: { kind: "end", total: 1 }, + systemReminder: "follow project conventions", + }) + }) + + it("tolerates literal inside the file body via greedy extraction", () => { + const inner = "1: prose mentioning inline" + const output = + `/src/md.md\nfile\n\n${inner}\n\n(End of file - total 1 lines)\n` + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ + path: "/src/md.md", + type: "file", + content: inner, + truncation: { kind: "end", total: 1 }, + }) + }) + + it("falls back to stdout on malformed wrapper (unexpected tail)", () => { + const output = + "/src/a.ts\nfile\n\n1: x\n\nunexpected trailing text" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ stdout: output }) + }) + + it("falls back to stdout when envelope does not match", () => { + const output = "/src/a.tsfile\n\nmissing newline\n" + const result = toolResultFromPart("read", {}, output, false) + expect(result.rawOutput).toEqual({ stdout: output }) + }) + + it("parses skill wrapper with files into structured rawOutput", () => { + const output = [ + '', + "# Skill: Frontend", + "", + "Do UI things.", + "", + "Base directory for this skill: file:///skills/frontend", + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + "/skills/frontend/scripts/build.sh", + "/skills/frontend/reference/guide.md", + "", + "", + ].join("\n") + const result = toolResultFromPart("skill", {}, output, false) + expect(result.content).toEqual([]) + expect(result.rawOutput).toEqual({ + name: "Frontend", + markdown: "Do UI things.", + baseDir: "file:///skills/frontend", + files: ["/skills/frontend/scripts/build.sh", "/skills/frontend/reference/guide.md"], + }) + }) + + it("parses skill wrapper with empty files list", () => { + const output = [ + '', + "# Skill: Bare", + "", + "body", + "", + "Base directory for this skill: file:///skills/bare", + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + "", + "", + "", + ].join("\n") + const result = toolResultFromPart("skill", {}, output, false) + expect(result.rawOutput).toEqual({ + name: "Bare", + markdown: "body", + baseDir: "file:///skills/bare", + files: [], + }) + }) + + it("parses skill wrapper with multi-paragraph markdown body", () => { + const output = [ + '', + "# Skill: Deep", + "", + "Para one.", + "", + "Para two with `code`.", + "", + "Base directory for this skill: file:///x", + "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", + "Note: file list is sampled.", + "", + "", + "", + "", + "", + ].join("\n") + const result = toolResultFromPart("skill", {}, output, false) + expect((result.rawOutput as any).markdown).toBe("Para one.\n\nPara two with `code`.") + }) + + it("falls back to stdout on malformed skill wrapper", () => { + const output = '\n# Skill: X\n\nbroken — no closing tag' + const result = toolResultFromPart("skill", {}, output, false) + expect(result.rawOutput).toEqual({ stdout: output }) + }) + + it("fences skill error output so the user sees why it failed", () => { + const output = 'Skill "Missing" not found. Available skills: Frontend' + const result = toolResultFromPart("skill", {}, output, true) + const text = (result.content[0] as any).content.text + expect(text).toContain(output) + expect(result.rawOutput).toEqual({ stderr: output }) + }) + + it("parses task wrapper into structured rawOutput", () => { + const output = [ + "task_id: ses_abc123 (for resuming to continue this task if needed)", + "", + "", + "Investigation complete. Found 3 issues in auth.ts.", + "", + ].join("\n") + const result = toolResultFromPart("task", { description: "investigate auth" }, output, false) + expect(result.content).toEqual([]) + expect(result.rawOutput).toEqual({ + taskId: "ses_abc123", + result: "Investigation complete. Found 3 issues in auth.ts.", + }) + }) + + it("parses task wrapper with multiline result preserving internal newlines", () => { + const output = [ + "task_id: ses_xyz (for resuming to continue this task if needed)", + "", + "", + "Line one", + "", + "Line three", + "", + ].join("\n") + const result = toolResultFromPart("task", {}, output, false) + expect((result.rawOutput as any).result).toBe("Line one\n\nLine three") + }) + + it("parses task wrapper with empty result", () => { + const output = [ + "task_id: ses_empty (for resuming to continue this task if needed)", + "", + "", + "", + "", + ].join("\n") + const result = toolResultFromPart("task", {}, output, false) + expect(result.rawOutput).toEqual({ taskId: "ses_empty", result: "" }) + }) + + it("falls back to stdout on malformed task wrapper", () => { + const output = "no task_id prefix\n\nhi\n" + const result = toolResultFromPart("task", {}, output, false) + expect(result.rawOutput).toEqual({ stdout: output }) + }) + + it("fences task error output so the user sees why it failed", () => { + const output = "Task aborted by user" + const result = toolResultFromPart("task", {}, output, true) + const text = (result.content[0] as any).content.text + expect(text).toContain(output) + expect(result.rawOutput).toEqual({ stderr: output }) + }) + + it("preserves widened fence when output contains triple-backticks", () => { + const output = "before\n```\nnested\n```\nafter" + const result = toolResultFromPart("unknown", {}, output, false) + const text = (result.content[0] as any).content.text + expect(text.startsWith("````\n")).toBe(true) + expect(text.endsWith("\n````")).toBe(true) + }) +}) + +describe("permissionDisplayInfo", () => { + it("formats edit permission from lowercase metadata keys", () => { + const info = permissionDisplayInfo( + "edit", + { filepath: "/abs/foo.ts", diff: "--- a\n+++ b\n" }, + CWD, + ) + expect(info.title).toBe("Edit /abs/foo.ts") + expect(info.kind).toBe("edit") + expect(info.locations).toEqual([{ path: "/abs/foo.ts" }]) + expect(info.content).toHaveLength(1) + }) + + it("formats bash permission with description fallback", () => { + const info = permissionDisplayInfo("bash", { command: "rm -rf /", description: "Nuke" }, CWD) + expect(info.title).toBe("Nuke") + expect(info.kind).toBe("execute") + }) + + it("formats webfetch permission", () => { + const info = permissionDisplayInfo("webfetch", { url: "https://example.com" }, CWD) + expect(info.title).toBe("Fetch https://example.com") + expect(info.kind).toBe("fetch") + }) + + it("falls back to permission name on unknown type", () => { + const info = permissionDisplayInfo("doom_loop", {}, CWD) + expect(info.title).toBe("doom_loop") + expect(info.kind).toBe("other") + }) + + it("resolves relative edit filepath against cwd", () => { + const cwd = resolve("/ws") + const info = permissionDisplayInfo("edit", { filepath: "rel/foo.ts" }, cwd) + expect(info.locations).toEqual([{ path: resolve(cwd, "rel/foo.ts") }]) + }) +}) From fe33dd1a3fc8e3fe476bb42227da47af0b83121f Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Fri, 15 May 2026 21:31:13 +0300 Subject: [PATCH 5/7] chore: refresh PR mergeability From 206042fdbad31250998957af5e2792ff8c4b222a Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Fri, 5 Jun 2026 02:07:31 +0300 Subject: [PATCH 6/7] fix(acp): port tool call raw input updates --- packages/opencode/src/acp/event.ts | 22 +- packages/opencode/src/acp/tool-format.ts | 716 ----------------- packages/opencode/src/acp/tool.ts | 170 ++++- packages/opencode/test/acp/event.test.ts | 31 +- .../opencode/test/acp/tool-format.test.ts | 722 ------------------ packages/opencode/test/acp/tool.test.ts | 8 +- 6 files changed, 183 insertions(+), 1486 deletions(-) delete mode 100644 packages/opencode/src/acp/tool-format.ts delete mode 100644 packages/opencode/test/acp/tool-format.test.ts diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index 05b8b77b370d..d7140e584818 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -87,7 +87,9 @@ export class Subscription { for (const part of message.parts) { await this.recordFetchedPart(message.info.sessionID, message, part) if (part.type === "tool") { - await this.handleToolPart(message.info.sessionID, part) + const session = await Effect.runPromise(this.input.session.tryGet(message.info.sessionID)) + const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined + await this.handleToolPart(message.info.sessionID, part, cwd ?? session?.cwd ?? process.cwd()) continue } await this.replayContentPart(message, part) @@ -152,7 +154,7 @@ export class Subscription { }), ) if (part.type === "tool") { - await this.handleToolPart(session.id, part) + await this.handleToolPart(session.id, part, session.cwd) } } @@ -240,8 +242,8 @@ export class Subscription { ) } - private async handleToolPart(sessionId: string, part: ToolPart) { - await this.toolStart(sessionId, part) + private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) { + await this.toolStart(sessionId, part, cwd) switch (part.state.status) { case "pending": @@ -249,7 +251,7 @@ export class Subscription { return case "running": - await this.runningTool(sessionId, part) + await this.runningTool(sessionId, part, cwd) return case "completed": @@ -262,6 +264,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -277,6 +280,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -284,7 +288,7 @@ export class Subscription { } } - private async runningTool(sessionId: string, part: ToolPart) { + private async runningTool(sessionId: string, part: ToolPart, cwd: string) { if (part.state.status !== "running") return const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined @@ -298,6 +302,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -315,12 +320,13 @@ export class Subscription { toolName: part.tool, state: part.state, output, + cwd, }), }, }) } - private async toolStart(sessionId: string, part: ToolPart) { + private async toolStart(sessionId: string, part: ToolPart, cwd: string) { if (this.toolStarts.has(part.callID)) return this.toolStarts.add(part.callID) await this.input.connection.sessionUpdate({ @@ -330,6 +336,8 @@ export class Subscription { ...pendingToolCall({ toolCallId: part.callID, toolName: part.tool, + state: part.state, + cwd, }), }, }) diff --git a/packages/opencode/src/acp/tool-format.ts b/packages/opencode/src/acp/tool-format.ts deleted file mode 100644 index 34df68ffc7fa..000000000000 --- a/packages/opencode/src/acp/tool-format.ts +++ /dev/null @@ -1,716 +0,0 @@ -import { isAbsolute, resolve } from "path" -import type { ToolCallContent, ToolKind } from "@agentclientprotocol/sdk" - -export interface ToolCallInfo { - title: string - kind: ToolKind - content: ToolCallContent[] - locations: { path: string; line?: number }[] - rawInput: unknown -} - -export interface ToolResultInfo { - content: ToolCallContent[] - rawOutput: unknown -} - -type ToolResultAttachment = { mime: string; url: string } & Record - -interface ToolResultOptions { - metadata?: unknown - attachments?: ToolResultAttachment[] -} - -const FENCE_RE = /^`{3,}/gm - -function normalize(name: string): string { - return name.toLowerCase().replace(/^mcp__acp__/, "") -} - -function truncate(str: string, max: number): string { - return str.length > max ? str.substring(0, max - 3) + "..." : str -} - -export function fenceWith(text: string, lang: string): string { - if (!text) return text - let fence = "```" - for (const match of text.matchAll(FENCE_RE)) { - while (match[0].length >= fence.length) fence += "`" - } - const trimmed = text.replace(/\n+$/, "") - return `${fence}${lang}\n${trimmed}\n${fence}` -} - -function textContent(text: string): ToolCallContent { - return { type: "content", content: { type: "text", text } } -} - -function diffContent(path: string, oldText: string | null, newText: string): ToolCallContent { - return { type: "diff", path, oldText, newText } -} - -function imageContents(attachments: ToolResultAttachment[] | undefined): ToolCallContent[] { - return (attachments ?? []).flatMap((attachment): ToolCallContent[] => { - const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) - const mime = match?.[1] ?? attachment.mime - if (!mime.startsWith("image/")) return [] - const data = match?.[2] - if (data === undefined) return [] - return [ - { - type: "content", - content: { - type: "image", - mimeType: mime, - data, - }, - }, - ] - }) -} - -function withImages(content: ToolCallContent[], options?: ToolResultOptions): ToolCallContent[] { - const images = imageContents(options?.attachments) - if (!images.length) return content - return [...content, ...images] -} - -function rawOutputWithOptions(rawOutput: unknown, options?: ToolResultOptions): unknown { - if (options?.metadata === undefined && !options?.attachments?.length) return rawOutput - return { - ...((rawOutput !== null && typeof rawOutput === "object" && !Array.isArray(rawOutput) - ? rawOutput - : { value: rawOutput }) as Record), - ...(options.metadata !== undefined ? { metadata: options.metadata } : {}), - ...(options.attachments?.length ? { attachments: options.attachments } : {}), - } -} - -function str(v: unknown): string { - return typeof v === "string" ? v : "" -} - -type ReadTruncation = - | { kind: "end"; total: number } - | { kind: "more"; from: number; to: number; total: number; next: number } - | { kind: "cut"; from: number; to: number; maxBytes: string; next: number } - | { kind: "dir_partial"; shown: number; total: number; next: number } - | { kind: "dir_full"; total: number } - -interface ParsedReadOutput { - path: string - type: "file" | "directory" - content: string - truncation?: ReadTruncation - systemReminder?: string -} - -const FILE_FOOTER_END = /\n\n\(End of file - total (\d+) lines\)$/ -const FILE_FOOTER_MORE = /\n\n\(Showing lines (\d+)-(\d+) of (\d+)\. Use offset=(\d+) to continue\.\)$/ -const FILE_FOOTER_CUT = /\n\n\(Output capped at (.+?)\. Showing lines (\d+)-(\d+)\. Use offset=(\d+) to continue\.\)$/ -const DIR_FOOTER_PARTIAL = /\n\(Showing (\d+) of (\d+) entries\. Use 'offset' parameter to read beyond entry (\d+)\)$/ -const DIR_FOOTER_FULL = /\n\((\d+) entries\)$/ -const SYSTEM_REMINDER_RE = /^\n\n\n([\s\S]*)\n<\/system-reminder>$/ - -interface ParsedSkillOutput { - name: string - markdown: string - baseDir: string - files: string[] -} - -interface ParsedTaskOutput { - taskId: string - result: string -} - -function escapeRegExp(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} - -export function parseSkillOutput(output: string): ParsedSkillOutput | null { - try { - const open = output.match(/^\n/) - if (!open) return null - const name = open[1] - const close = "\n" - if (!output.endsWith(close)) return null - const body = output.slice(open[0].length, output.length - close.length) - - const headerRe = new RegExp(`^# Skill: ${escapeRegExp(name)}\\n\\n`) - const header = body.match(headerRe) - if (!header) return null - const afterHeader = body.slice(header[0].length) - - const skillFilesOpen = "\n\n\n" - const skillFilesClose = "\n" - const openIdx = afterHeader.lastIndexOf(skillFilesOpen) - if (openIdx < 0) return null - if (!afterHeader.endsWith(skillFilesClose)) return null - const preFiles = afterHeader.slice(0, openIdx) - const filesBlock = afterHeader.slice(openIdx + skillFilesOpen.length, afterHeader.length - skillFilesClose.length) - - const preambleRe = - /\n\nBase directory for this skill: (\S+)\nRelative paths in this skill \(e\.g\., scripts\/, reference\/\) are relative to this base directory\.\nNote: file list is sampled\.$/ - const preamble = preFiles.match(preambleRe) - if (!preamble) return null - const markdown = preFiles.slice(0, preFiles.length - preamble[0].length) - const baseDir = preamble[1] - - let files: string[] = [] - if (filesBlock !== "") { - files = filesBlock.split("\n").map((line) => { - const m = line.match(/^(.*)<\/file>$/) - if (!m) throw new Error("bad file line") - return m[1] - }) - } - - return { name, markdown, baseDir, files } - } catch { - return null - } -} - -export function parseTaskOutput(output: string): ParsedTaskOutput | null { - try { - const m = output.match( - /^task_id: (\S+) \(for resuming to continue this task if needed\)\n\n\n([\s\S]*)\n<\/task_result>$/, - ) - if (!m) return null - return { taskId: m[1], result: m[2] } - } catch { - return null - } -} - -export function parseReadOutput(output: string): ParsedReadOutput | null { - try { - const envelope = output.match(/^([^\n]*)<\/path>\n(file|directory)<\/type>\n/) - if (!envelope) return null - const path = envelope[1] - const type = envelope[2] as "file" | "directory" - const afterEnvelope = output.slice(envelope[0].length) - - const openTag = type === "file" ? "\n" : "\n" - const closeTag = type === "file" ? "\n" : "\n" - if (!afterEnvelope.startsWith(openTag)) return null - const bodyStart = openTag.length - const closeIdx = afterEnvelope.lastIndexOf(closeTag) - if (closeIdx < bodyStart) return null - - const body = afterEnvelope.slice(bodyStart, closeIdx) - const afterClose = afterEnvelope.slice(closeIdx + closeTag.length) - - let systemReminder: string | undefined - if (afterClose.length > 0) { - const sr = afterClose.match(SYSTEM_REMINDER_RE) - if (!sr) return null - systemReminder = sr[1] - } - - let truncation: ReadTruncation | undefined - let content = body - if (type === "file") { - const end = body.match(FILE_FOOTER_END) - const more = body.match(FILE_FOOTER_MORE) - const cut = body.match(FILE_FOOTER_CUT) - if (end) { - content = body.slice(0, body.length - end[0].length) - truncation = { kind: "end", total: Number(end[1]) } - } else if (more) { - content = body.slice(0, body.length - more[0].length) - truncation = { - kind: "more", - from: Number(more[1]), - to: Number(more[2]), - total: Number(more[3]), - next: Number(more[4]), - } - } else if (cut) { - content = body.slice(0, body.length - cut[0].length) - truncation = { - kind: "cut", - maxBytes: cut[1], - from: Number(cut[2]), - to: Number(cut[3]), - next: Number(cut[4]), - } - } - } else { - const partial = body.match(DIR_FOOTER_PARTIAL) - const full = body.match(DIR_FOOTER_FULL) - if (partial) { - content = body.slice(0, body.length - partial[0].length) - truncation = { - kind: "dir_partial", - shown: Number(partial[1]), - total: Number(partial[2]), - next: Number(partial[3]), - } - } else if (full) { - content = body.slice(0, body.length - full[0].length) - truncation = { kind: "dir_full", total: Number(full[1]) } - } - } - - const result: ParsedReadOutput = { path, type, content } - if (truncation) result.truncation = truncation - if (systemReminder !== undefined) result.systemReminder = systemReminder - return result - } catch { - return null - } -} - -function num(v: unknown): number | undefined { - return typeof v === "number" && Number.isFinite(v) ? v : undefined -} - -function abs(p: string, cwd: string): string { - return p && !isAbsolute(p) ? resolve(cwd, p) : p -} - -export function toolCallFromPart(tool: string, input: Record, cwd: string): ToolCallInfo { - const name = normalize(tool) - - switch (name) { - case "bash": { - const command = str(input.command) - const description = str(input.description) - const workdir = str(input.workdir) - const resolvedWorkdir = workdir ? abs(workdir, cwd) : cwd - return { - title: description || command || "Terminal", - kind: "other", - content: [], - locations: [{ path: resolvedWorkdir }], - rawInput: workdir ? input : { ...input, cwd }, - } - } - - case "read": { - const filePath = str(input.filePath) - const offset = num(input.offset) ?? 1 - const limit = num(input.limit) ?? 0 - const hasMeaningfulOffset = offset > 1 - const rangeStart = Math.max(offset, 1) - let suffix = "" - if (limit) { - suffix = ` (${rangeStart} - ${rangeStart + limit - 1})` - } else if (hasMeaningfulOffset) { - suffix = ` (from line ${offset})` - } - return { - title: filePath ? `Read ${filePath}${suffix}` : "Read File", - kind: "read", - content: [], - locations: filePath ? [{ path: abs(filePath, cwd), ...(hasMeaningfulOffset ? { line: offset } : {}) }] : [], - rawInput: input, - } - } - - case "list": { - const path = str(input.path) - return { - title: path ? `List \`${path}\`` : "List directory", - kind: "read", - content: [], - locations: path ? [{ path: abs(path, cwd) }] : [], - rawInput: input, - } - } - - case "edit": { - const filePath = str(input.filePath) - const oldString = str(input.oldString) - const newString = str(input.newString) - return { - title: filePath ? `Edit \`${filePath}\`` : "Edit", - kind: "edit", - content: filePath ? [diffContent(abs(filePath, cwd), oldString, newString)] : [], - locations: filePath ? [{ path: abs(filePath, cwd) }] : [], - rawInput: input, - } - } - - case "write": { - const filePath = str(input.filePath) - const content = str(input.content) - return { - title: filePath ? `Write ${filePath}` : "Write", - kind: "edit", - content: filePath ? [diffContent(abs(filePath, cwd), null, content)] : [], - locations: filePath ? [{ path: abs(filePath, cwd) }] : [], - rawInput: input, - } - } - - case "glob": { - const path = str(input.path) - const pattern = str(input.pattern) - let label = "Find" - if (path) label += ` \`${path}\`` - if (pattern) label += ` \`${pattern}\`` - return { - title: label, - kind: "search", - content: [], - locations: path ? [{ path: abs(path, cwd) }] : [], - rawInput: input, - } - } - - case "grep": { - const pattern = str(input.pattern) - const path = str(input.path) - let label = "grep" - if (pattern) label += ` "${truncate(pattern, 30)}"` - if (path) label += ` ${path}` - return { - title: label, - kind: "search", - content: [], - locations: path ? [{ path: abs(path, cwd) }] : [], - rawInput: input, - } - } - - case "webfetch": { - const url = str(input.url) - const prompt = str(input.prompt) - return { - title: url ? `Fetch ${truncate(url, 40)}` : "Fetch", - kind: "fetch", - content: prompt ? [textContent(prompt)] : [], - locations: [], - rawInput: input, - } - } - - case "websearch": { - const query = str(input.query) - return { - title: query ? `"${truncate(query, 40)}"` : "Search", - kind: "fetch", - content: [], - locations: [], - rawInput: input, - } - } - - case "task": { - const description = str(input.description) - const prompt = str(input.prompt) - return { - title: description || "Task", - kind: "think", - content: prompt ? [textContent(prompt)] : [], - locations: [], - rawInput: input, - } - } - - case "todowrite": - case "todoread": { - return { - title: "Update TODOs", - kind: "think", - content: [], - locations: [], - rawInput: input, - } - } - - case "plan_exit": { - return { - title: "Exit Plan Mode", - kind: "switch_mode", - content: [], - locations: [], - rawInput: input, - } - } - - case "plan_enter": { - return { - title: "Enter Plan Mode", - kind: "switch_mode", - content: [], - locations: [], - rawInput: input, - } - } - - case "apply_patch": { - const patchText = str(input.patchText) - return { - title: "Apply Patch", - kind: "edit", - content: patchText ? [textContent(patchText)] : [], - locations: [], - rawInput: input, - } - } - - case "multiedit": { - return { - title: "Multi Edit", - kind: "edit", - content: [], - locations: [], - rawInput: input, - } - } - - case "batch": { - return { - title: "Batch", - kind: "other", - content: [], - locations: [], - rawInput: input, - } - } - - case "skill": { - const skillName = str(input.name) - return { - title: skillName ? `Skill: ${skillName}` : "Skill", - kind: "other", - content: [], - locations: [], - rawInput: input, - } - } - - case "question": { - const question = str(input.question) || str(input.query) - return { - title: question ? truncate(question, 40) : "Question", - kind: "other", - content: [], - locations: [], - rawInput: input, - } - } - - case "lsp": { - return { - title: "LSP", - kind: "other", - content: [], - locations: [], - rawInput: input, - } - } - - case "codesearch": { - const query = str(input.query) - return { - title: query ? `Search: ${truncate(query, 30)}` : "Code Search", - kind: "search", - content: [], - locations: [], - rawInput: input, - } - } - - default: { - const description = str(input.description) - const command = str(input.command) - const title = description || command || tool - return { - title: truncate(title, 50), - kind: "other", - content: [], - locations: [], - rawInput: input, - } - } - } -} - -export function toolResultFromPart( - tool: string, - input: Record, - output: string, - isError: boolean, - cwd: string, - options?: ToolResultOptions, -): ToolResultInfo { - const name = normalize(tool) - - if (name === "bash") { - const text = fenceWith(output, isError ? "" : "sh") - return { - content: withImages([textContent(text)], options), - rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), - } - } - - const content: ToolCallContent[] = [textContent(fenceWith(output, ""))] - - switch (name) { - case "read": - case "list": { - if (isError) { - return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } - } - const parsed = parseReadOutput(output) - return { - content: withImages([], options), - rawOutput: rawOutputWithOptions(parsed ?? { stdout: output }, options), - } - } - - case "skill": { - if (isError) { - return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } - } - const parsed = parseSkillOutput(output) - return { - content: withImages([], options), - rawOutput: rawOutputWithOptions(parsed ?? { stdout: output }, options), - } - } - - case "task": { - if (isError) { - return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } - } - const parsed = parseTaskOutput(output) - return { - content: withImages([], options), - rawOutput: rawOutputWithOptions(parsed ?? { stdout: output }, options), - } - } - - case "edit": { - const filePath = str(input.filePath) - const oldString = str(input.oldString) - const newString = str(input.newString) - if (filePath && !isError) { - content.push(diffContent(abs(filePath, cwd), oldString, newString)) - } - return { - content: withImages(content, options), - rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), - } - } - - case "apply_patch": { - const patchText = str(input.patchText) - if (isError) { - return { content: withImages(content, options), rawOutput: rawOutputWithOptions({ stderr: output }, options) } - } - const successContent: ToolCallContent[] = patchText ? [textContent(fenceWith(patchText, "diff"))] : [] - return { - content: withImages(successContent, options), - rawOutput: rawOutputWithOptions({ stdout: output }, options), - } - } - - case "write": { - const filePath = str(input.filePath) - const fileContent = str(input.content) - if (filePath && !isError) { - content.push(diffContent(abs(filePath, cwd), null, fileContent)) - } - return { - content: withImages(content, options), - rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), - } - } - - default: { - return { - content: withImages(content, options), - rawOutput: rawOutputWithOptions(isError ? { stderr: output } : { stdout: output }, options), - } - } - } -} - -export function permissionDisplayInfo( - permission: string, - metadata: Record, - cwd: string, -): ToolCallInfo { - const name = normalize(permission) - switch (name) { - case "edit": - case "write": - case "apply_patch": { - const filepath = str(metadata.filepath) - const diff = str(metadata.diff) - return { - title: filepath ? `Edit ${filepath}` : "Edit", - kind: "edit", - content: diff ? [textContent(diff)] : [], - locations: filepath ? [{ path: abs(filepath, cwd) }] : [], - rawInput: metadata, - } - } - case "bash": { - const command = str(metadata.command) - const description = str(metadata.description) - return { - title: description || command || "Terminal", - kind: "execute", - content: [], - locations: [], - rawInput: metadata, - } - } - case "webfetch": { - const url = str(metadata.url) - return { - title: url ? `Fetch ${truncate(url, 40)}` : "Fetch", - kind: "fetch", - content: [], - locations: [], - rawInput: metadata, - } - } - case "websearch": { - const query = str(metadata.query) - return { - title: query ? `"${truncate(query, 40)}"` : "Search", - kind: "fetch", - content: [], - locations: [], - rawInput: metadata, - } - } - case "task": { - const description = str(metadata.description) - return { - title: description || "Task", - kind: "think", - content: [], - locations: [], - rawInput: metadata, - } - } - case "skill": { - const skillName = str(metadata.name) - return { - title: skillName ? `Skill: ${skillName}` : "Skill", - kind: "other", - content: [], - locations: [], - rawInput: metadata, - } - } - default: { - return { - title: permission || "Permission", - kind: "other", - content: [], - locations: [], - rawInput: metadata, - } - } - } -} diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index 0e8b4f098501..22e1c159191e 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -1,4 +1,12 @@ -import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" +import { isAbsolute, resolve } from "path" +import type { + ToolCall, + ToolCallContent, + ToolCallLocation, + ToolCallStatus, + ToolCallUpdate, + ToolKind, +} from "@agentclientprotocol/sdk" export type ToolInput = Record @@ -29,18 +37,33 @@ export type ErrorToolState = { readonly metadata?: unknown } +type ToolState = + | { readonly status: "pending"; readonly input: ToolInput; readonly title?: string } + | RunningToolState + | (CompletedToolState & { readonly title?: string }) + | ErrorToolState + export type ImageAttachment = { readonly mimeType: string readonly data: string } +const TOOL_STATUS_MAP = { + pending: "pending", + running: "in_progress", + completed: "completed", + error: "failed", +} as const satisfies Record + +const FENCE_RE = /^`{3,}/gm + export function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() switch (tool) { case "bash": case "shell": - return "execute" + return "other" case "webfetch": return "fetch" @@ -69,10 +92,14 @@ export function toToolKind(toolName: string): ToolKind { } } -export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] { +export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] { const tool = toolName.toLocaleLowerCase() switch (tool) { + case "bash": + case "shell": + return shellLocation(input, cwd) + case "read": case "edit": case "write": @@ -88,10 +115,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio case "context7_get_library_docs": return locationFrom(input.path) - case "bash": - case "shell": - return [] - default: return [] } @@ -99,7 +122,11 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] { const text = - toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output + toolName.toLocaleLowerCase() === "read" + ? (readDisplayText(state.metadata) ?? state.output) + : isShell(toolName) + ? fenceWith(state.output, "sh") + : state.output const content: ToolCallContent[] = [ { type: "content", @@ -118,7 +145,23 @@ export function completedToolContent(toolName: string, state: CompletedToolState return content } -export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall { +export function pendingToolCall(input: { + readonly toolCallId: string + readonly toolName: string + readonly state?: ToolState + readonly cwd?: string +}): ToolCall { + if (input.state) { + return { + toolCallId: input.toolCallId, + title: toolTitle(input.toolName, input.state), + kind: toToolKind(input.toolName), + status: TOOL_STATUS_MAP[input.state.status], + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), + } + } + return { toolCallId: input.toolCallId, title: input.toolName, @@ -134,6 +177,7 @@ export function runningToolUpdate(input: { readonly toolName: string readonly state: RunningToolState readonly output?: string + readonly cwd?: string }): ToolCallUpdate { const content = input.output ? [ @@ -141,7 +185,7 @@ export function runningToolUpdate(input: { type: "content" as const, content: { type: "text" as const, - text: input.output, + text: isShell(input.toolName) ? fenceWith(input.output, "sh") : input.output, }, }, ] @@ -151,9 +195,9 @@ export function runningToolUpdate(input: { toolCallId: input.toolCallId, status: "in_progress", kind: toToolKind(input.toolName), - title: input.state.title ?? input.toolName, - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), ...(content ? { content } : {}), } } @@ -162,30 +206,33 @@ export function duplicateRunningToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly state: RunningToolState + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "in_progress", kind: toToolKind(input.toolName), - title: input.state.title ?? input.toolName, - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), } } export function completedToolUpdate(input: { readonly toolCallId: string readonly toolName: string - readonly state: CompletedToolState & { readonly title: string } + readonly state: CompletedToolState & { readonly title?: string } + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "completed", kind: toToolKind(input.toolName), - title: input.state.title, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), content: completedToolContent(input.toolName, input.state), - rawInput: input.state.input, - rawOutput: completedToolRawOutput(input.state), + rawOutput: completedToolRawOutputForTool(input.toolName, input.state), } } @@ -193,26 +240,25 @@ export function errorToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly state: ErrorToolState + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "failed", kind: toToolKind(input.toolName), - title: input.toolName, - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), content: [ { type: "content", content: { type: "text", - text: input.state.error, + text: isShell(input.toolName) ? fenceWith(input.state.error, "sh") : input.state.error, }, }, ], - rawOutput: { - error: input.state.error, - metadata: input.state.metadata, - }, + rawOutput: errorToolRawOutputForTool(input.toolName, input.state), } } @@ -249,6 +295,76 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { return stringValue((state.metadata as Record).output) } +function completedToolRawOutputForTool(toolName: string, state: CompletedToolState) { + if (!isShell(toolName)) return completedToolRawOutput(state) + return { + stdout: state.output, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + ...(state.attachments?.length ? { attachments: state.attachments } : {}), + } +} + +function errorToolRawOutputForTool(toolName: string, state: ErrorToolState) { + if (!isShell(toolName)) { + return { + error: state.error, + metadata: state.metadata, + } + } + return { + stderr: state.error, + ...(state.metadata !== undefined ? { metadata: state.metadata } : {}), + } +} + +function toolTitle(toolName: string, state: ToolState) { + if (isShell(toolName)) return stringValue(state.input.description) ?? shellCommand(state.input) ?? "Terminal" + return ("title" in state && state.title) || toolName +} + +function rawInput(toolName: string, input: ToolInput, cwd?: string) { + if (!isShell(toolName)) return input + const workdir = shellWorkdir(input, cwd) + if (!workdir || input.cwd || input.workdir || input.workingDir || input.directory) return input + return { ...input, cwd: workdir } +} + +function shellLocation(input: ToolInput, cwd?: string): ToolCallLocation[] { + const workdir = shellWorkdir(input, cwd) + if (!workdir || (!shellCommand(input) && !input.workdir && !input.cwd && !input.workingDir && !input.directory)) return [] + return [{ path: workdir }] +} + +function shellCommand(input: ToolInput) { + return stringValue(input.command) ?? stringValue(input.cmd) +} + +function shellWorkdir(input: ToolInput, cwd?: string) { + const workdir = + stringValue(input.workdir) ?? stringValue(input.cwd) ?? stringValue(input.workingDir) ?? stringValue(input.directory) + return resolvePath(workdir, cwd) ?? cwd +} + +function resolvePath(value: string | undefined, cwd?: string) { + if (!value) return undefined + if (isAbsolute(value)) return value + return resolve(cwd ?? process.cwd(), value) +} + +function isShell(toolName: string) { + const tool = toolName.toLocaleLowerCase() + return tool === "bash" || tool === "shell" +} + +function fenceWith(text: string, lang: string) { + if (!text) return text + let fence = "```" + for (const match of text.matchAll(FENCE_RE)) { + while (match[0].length >= fence.length) fence += "`" + } + return `${fence}${lang}\n${text.replace(/\n+$/, "")}\n${fence}` +} + export const mapToolKind = toToolKind export const extractLocations = toLocations export const buildCompletedToolContent = completedToolContent diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 6edd7f0fbba0..af787076a27e 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -517,7 +517,7 @@ describe("acp event routing", () => { expect(harness.updates).toHaveLength(0) }) - it("emits synthetic pending before the first running tool update", async () => { + it("emits first running tool call with raw input before the first running tool update", async () => { const harness = createHarness() await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" })) @@ -527,7 +527,14 @@ describe("acp event routing", () => { "tool_call", "tool_call_update", ]) - expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" }) + expect(harness.updates[0]?.update).toMatchObject({ + status: "in_progress", + toolCallId: "call_1", + title: "printf hello", + kind: "other", + locations: [{ path: "/workspace" }], + rawInput: { cmd: "printf hello", cwd: "/workspace" }, + }) expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" }) }) @@ -557,7 +564,7 @@ describe("acp event routing", () => { expect(updates).toHaveLength(3) expect(updates[1]?.update).toMatchObject({ sessionUpdate: "tool_call_update", - content: [{ type: "content", content: { type: "text", text: "same" } }], + content: [{ type: "content", content: { type: "text", text: "```sh\nsame\n```" } }], }) expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" }) expect("content" in updates[2]!.update).toBe(false) @@ -590,8 +597,8 @@ describe("acp event routing", () => { .filter((item) => item.update.sessionUpdate === "tool_call_update") .map((item) => ("content" in item.update ? item.update.content : undefined)), ).toEqual([ - [{ type: "content", content: { type: "text", text: "repeat" } }], - [{ type: "content", content: { type: "text", text: "repeat" } }], + [{ type: "content", content: { type: "text", text: "```sh\nrepeat\n```" } }], + [{ type: "content", content: { type: "text", text: "```sh\nrepeat\n```" } }], ]) }) @@ -605,8 +612,8 @@ describe("acp event routing", () => { sessionUpdate: "tool_call_update", toolCallId: "call_done", status: "completed", - content: [{ type: "content", content: { type: "text", text: "finished" } }], - rawOutput: { output: "finished", metadata: { exit: 0 } }, + content: [{ type: "content", content: { type: "text", text: "```sh\nfinished\n```" } }], + rawOutput: { stdout: "finished", metadata: { exit: 0 } }, }) }) @@ -669,8 +676,8 @@ describe("acp event routing", () => { sessionUpdate: "tool_call_update", toolCallId: "call_error", status: "failed", - content: [{ type: "content", content: { type: "text", text: "failed hard" } }], - rawOutput: { error: "failed hard", metadata: { exit: 1 } }, + content: [{ type: "content", content: { type: "text", text: "```sh\nfailed hard\n```" } }], + rawOutput: { stderr: "failed hard", metadata: { exit: 1 } }, }) }) @@ -696,14 +703,14 @@ describe("acp event routing", () => { expect( toolUpdates(harness.updates) .filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed") - .map((item) => ("content" in item.update ? item.update.content : [])), + .map((item) => ("content" in item.update ? item.update.content : [])), ).toEqual([ [ - { type: "content", content: { type: "text", text: "live" } }, + { type: "content", content: { type: "text", text: "```sh\nlive\n```" } }, { type: "content", content: { type: "image", mimeType: "image/png", data: image } }, ], [ - { type: "content", content: { type: "text", text: "replayed" } }, + { type: "content", content: { type: "text", text: "```sh\nreplayed\n```" } }, { type: "content", content: { type: "image", mimeType: "image/png", data: image } }, ], ]) diff --git a/packages/opencode/test/acp/tool-format.test.ts b/packages/opencode/test/acp/tool-format.test.ts deleted file mode 100644 index 8b495867b113..000000000000 --- a/packages/opencode/test/acp/tool-format.test.ts +++ /dev/null @@ -1,722 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { isAbsolute, resolve } from "path" -import { - permissionDisplayInfo, - toolCallFromPart as _toolCallFromPart, - toolResultFromPart as _toolResultFromPart, -} from "../../src/acp/tool-format" - -const CWD = process.cwd() -const toolCallFromPart = (tool: string, input: Record, cwd: string = CWD) => - _toolCallFromPart(tool, input, cwd) -const toolResultFromPart = ( - tool: string, - input: Record, - output: string, - isError: boolean, - cwd: string = CWD, -) => _toolResultFromPart(tool, input, output, isError, cwd) - -describe("toolCallFromPart", () => { - describe("bash", () => { - it("formats bash command with description and workdir location", () => { - const result = toolCallFromPart("bash", { command: "ls -la", description: "List files", workdir: "/home" }) - expect(result.title).toBe("List files") - expect(result.kind).toBe("other") - expect(result.locations).toEqual([{ path: "/home" }]) - expect(result.rawInput).toEqual({ command: "ls -la", description: "List files", workdir: "/home" }) - }) - - it("falls back to command when no description", () => { - const result = toolCallFromPart("bash", { command: "npm install" }) - expect(result.title).toBe("npm install") - }) - - it("normalizes mcp__acp__ prefix", () => { - const result = toolCallFromPart("mcp__acp__bash", { command: "echo hi" }) - expect(result.title).toBe("echo hi") - }) - - it("falls back to session cwd in locations and rawInput when workdir is absent", () => { - const cwd = resolve("/workspace/project") - const result = toolCallFromPart("bash", { command: "ls" }, cwd) - expect(result.locations).toEqual([{ path: cwd }]) - expect(result.rawInput).toEqual({ command: "ls", cwd }) - }) - - it("does not inject cwd into rawInput when workdir is provided", () => { - const result = toolCallFromPart("bash", { command: "ls", workdir: "/opt" }, resolve("/workspace")) - expect(result.rawInput).toEqual({ command: "ls", workdir: "/opt" }) - }) - }) - - describe("read", () => { - it("formats read with file path", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts" }) - expect(result.title).toBe("Read /src/index.ts") - expect(result.kind).toBe("read") - expect(result.locations).toEqual([{ path: "/src/index.ts" }]) - }) - - it("uses 1-based line numbers when offset is provided (matches read tool contract)", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) - expect(result.locations).toEqual([{ path: "/src/index.ts", line: 10 }]) - }) - - it("omits line key when offset is zero", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 0 }) - expect(result.locations).toEqual([{ path: "/src/index.ts" }]) - }) - - it("omits line key when offset is 1 (redundant with default)", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 1 }) - expect(result.locations).toEqual([{ path: "/src/index.ts" }]) - expect(result.title).toBe("Read /src/index.ts") - }) - - it("includes line range suffix", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10, limit: 20 }) - expect(result.title).toBe("Read /src/index.ts (10 - 29)") - }) - - it("clamps range start to 1 when only limit is given", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", limit: 20 }) - expect(result.title).toBe("Read /src/index.ts (1 - 20)") - }) - - it("ignores non-finite offset and limit values", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: NaN, limit: Infinity }) - expect(result.title).toBe("Read /src/index.ts") - expect(result.locations).toEqual([{ path: "/src/index.ts" }]) - }) - - it("includes from-line suffix", () => { - const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) - expect(result.title).toBe("Read /src/index.ts (from line 10)") - }) - - it("falls back when no file path", () => { - const result = toolCallFromPart("read", {}) - expect(result.title).toBe("Read File") - expect(result.locations).toEqual([]) - }) - }) - - describe("edit", () => { - it("formats edit with diff content", () => { - const result = toolCallFromPart("edit", { - filePath: "/src/app.ts", - oldString: "foo", - newString: "bar", - }) - expect(result.title).toBe("Edit `/src/app.ts`") - expect(result.kind).toBe("edit") - expect(result.content).toEqual([{ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }]) - expect(result.locations).toEqual([{ path: "/src/app.ts" }]) - }) - - it("handles missing file path", () => { - const result = toolCallFromPart("edit", { oldString: "a", newString: "b" }) - expect(result.title).toBe("Edit") - expect(result.content).toEqual([]) - }) - }) - - describe("write", () => { - it("formats write with diff content (null oldText)", () => { - const result = toolCallFromPart("write", { filePath: "/new.ts", content: "hello" }) - expect(result.title).toBe("Write /new.ts") - expect(result.kind).toBe("edit") - expect(result.content).toEqual([{ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }]) - }) - }) - - describe("glob", () => { - it("formats glob with path and pattern", () => { - const result = toolCallFromPart("glob", { path: "/src", pattern: "*.ts" }) - expect(result.title).toBe("Find `/src` `*.ts`") - expect(result.kind).toBe("search") - }) - - it("absolutizes relative paths", () => { - const result = toolCallFromPart("glob", { path: "src", pattern: "*.ts" }) - expect(isAbsolute(result.locations[0].path)).toBe(true) - }) - - it("handles no path or pattern", () => { - const result = toolCallFromPart("glob", {}) - expect(result.title).toBe("Find") - }) - }) - - describe("grep", () => { - it("formats grep with pattern and path", () => { - const result = toolCallFromPart("grep", { pattern: "TODO", path: "/src" }) - expect(result.title).toBe('grep "TODO" /src') - expect(result.kind).toBe("search") - }) - - it("truncates long patterns", () => { - const long = "a".repeat(50) - const result = toolCallFromPart("grep", { pattern: long }) - expect(result.title.length).toBeLessThanOrEqual(40) - }) - }) - - describe("webfetch", () => { - it("formats fetch with url", () => { - const result = toolCallFromPart("webfetch", { url: "https://example.com", prompt: "get title" }) - expect(result.title).toBe("Fetch https://example.com") - expect(result.kind).toBe("fetch") - expect(result.content).toHaveLength(1) - }) - }) - - describe("websearch", () => { - it("formats search with query", () => { - const result = toolCallFromPart("websearch", { query: "bun test" }) - expect(result.title).toBe('"bun test"') - expect(result.kind).toBe("fetch") - }) - }) - - describe("task", () => { - it("formats task with description", () => { - const result = toolCallFromPart("task", { description: "Research APIs", prompt: "find REST patterns" }) - expect(result.title).toBe("Research APIs") - expect(result.kind).toBe("think") - expect(result.content).toHaveLength(1) - }) - }) - - describe("plan mode", () => { - it("emits switch_mode kind for plan_enter", () => { - const result = toolCallFromPart("plan_enter", {}) - expect(result.title).toBe("Enter Plan Mode") - expect(result.kind).toBe("switch_mode") - }) - - it("emits switch_mode kind for plan_exit", () => { - const result = toolCallFromPart("plan_exit", {}) - expect(result.title).toBe("Exit Plan Mode") - expect(result.kind).toBe("switch_mode") - }) - }) - - describe("bash kind pinning", () => { - it("uses kind 'other' instead of spec 'execute' to avoid Zed blue run-box styling", () => { - const result = toolCallFromPart("bash", { command: "ls", description: "List files" }) - expect(result.kind).toBe("other") - }) - }) - - describe("list", () => { - it("uses read kind for directory listing", () => { - const result = toolCallFromPart("list", { path: "/src" }) - expect(result.kind).toBe("read") - }) - }) - - describe("default", () => { - it("falls back to tool name for unknown tools", () => { - const result = toolCallFromPart("unknownTool", {}) - expect(result.title).toBe("unknownTool") - expect(result.kind).toBe("other") - }) - - it("uses description if available", () => { - const result = toolCallFromPart("custom", { description: "Custom action" }) - expect(result.title).toBe("Custom action") - }) - }) -}) - -describe("toolResultFromPart", () => { - describe("bash", () => { - it("returns stdout for success wrapped in shell code fence", () => { - const result = toolResultFromPart("bash", { command: "ls" }, "file1\nfile2", false) - expect(result.rawOutput).toEqual({ stdout: "file1\nfile2" }) - expect(result.content).toHaveLength(1) - expect(result.content[0]).toEqual({ - type: "content", - content: { type: "text", text: "```sh\nfile1\nfile2\n```" }, - }) - }) - - it("returns stderr for error", () => { - const result = toolResultFromPart("bash", { command: "bad" }, "command not found", true) - expect(result.rawOutput).toEqual({ stderr: "command not found" }) - }) - }) - - describe("edit", () => { - it("includes diff content on success", () => { - const result = toolResultFromPart( - "edit", - { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, - "Applied edit", - false, - ) - expect(result.rawOutput).toEqual({ stdout: "Applied edit" }) - expect(result.content).toHaveLength(2) - expect(result.content[1]).toEqual({ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }) - }) - - it("skips diff content on error and returns stderr", () => { - const result = toolResultFromPart( - "edit", - { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, - "old_string not found", - true, - ) - expect(result.content).toHaveLength(1) - expect(result.rawOutput).toEqual({ stderr: "old_string not found" }) - }) - }) - - describe("write", () => { - it("includes diff content with null oldText on success", () => { - const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Created", false) - expect(result.content).toHaveLength(2) - expect(result.content[1]).toEqual({ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }) - }) - - it("skips diff on error and returns stderr", () => { - const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Permission denied", true) - expect(result.content).toHaveLength(1) - expect(result.rawOutput).toEqual({ stderr: "Permission denied" }) - }) - }) - - describe("apply_patch", () => { - it("returns only a diff-fenced patch block on success (title=output dedupes body)", () => { - const patchText = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new" - const result = toolResultFromPart("apply_patch", { patchText }, "Patched", false) - expect(result.content).toHaveLength(1) - expect(result.content[0]).toEqual({ - type: "content", - content: { type: "text", text: "```diff\n" + patchText + "\n```" }, - }) - expect(result.rawOutput).toEqual({ stdout: "Patched" }) - }) - - it("returns empty content when patchText is absent on success", () => { - const result = toolResultFromPart("apply_patch", {}, "Patched", false) - expect(result.content).toHaveLength(0) - expect(result.rawOutput).toEqual({ stdout: "Patched" }) - }) - - it("keeps fenced error text and returns stderr on error", () => { - const result = toolResultFromPart("apply_patch", { patchText: "bad" }, "Failed", true) - expect(result.content).toHaveLength(1) - const text = (result.content[0] as any).content.text - expect(text).toMatch(/^```\n/) - expect(text).toContain("Failed") - expect(result.rawOutput).toEqual({ stderr: "Failed" }) - }) - }) - - describe("default", () => { - it("returns stdout for success", () => { - const result = toolResultFromPart("unknown", {}, "some output", false) - expect(result.rawOutput).toEqual({ stdout: "some output" }) - expect(result.content).toHaveLength(1) - }) - - it("returns stderr for error", () => { - const result = toolResultFromPart("unknown", {}, "error msg", true) - expect(result.rawOutput).toEqual({ stderr: "error msg" }) - }) - - it("wraps error output in markdown fence", () => { - const result = toolResultFromPart("unknown", {}, "some error", true) - const text = (result.content[0] as any).content.text - expect(text).toContain("```") - expect(text).toContain("some error") - }) - }) -}) - -describe("apply_patch canonical field", () => { - it("reads patchText from call input and emits content block", () => { - const patchText = "--- a/foo\n+++ b/foo\n@@ -1 +1 @@\n-x\n+y" - const call = toolCallFromPart("apply_patch", { patchText }) - expect(call.title).toBe("Apply Patch") - expect(call.kind).toBe("edit") - expect(call.content).toEqual([{ type: "content", content: { type: "text", text: patchText } }]) - }) - - it("produces empty content when patchText is absent", () => { - const call = toolCallFromPart("apply_patch", {}) - expect(call.content).toEqual([]) - }) -}) - -describe("path resolution against cwd", () => { - it("resolves relative filePath against the passed cwd (not process.cwd)", () => { - const cwd = resolve("/workspace/project") - const result = toolCallFromPart("read", { filePath: "src/index.ts" }, cwd) - expect(result.locations).toEqual([{ path: resolve(cwd, "src/index.ts") }]) - }) - - it("leaves absolute paths untouched", () => { - const absolutePath = resolve("/abs/path.ts") - const result = toolCallFromPart("read", { filePath: absolutePath }, resolve("/workspace/project")) - expect(result.locations).toEqual([{ path: absolutePath }]) - }) - - it("resolves relative path in edit result content", () => { - const cwd = resolve("/workspace") - const result = toolResultFromPart( - "edit", - { filePath: "rel/file.ts", oldString: "a", newString: "b" }, - "ok", - false, - cwd, - ) - expect(result.content[1]).toEqual({ - type: "diff", - path: resolve(cwd, "rel/file.ts"), - oldText: "a", - newText: "b", - }) - }) - - it("resolves relative bash workdir against cwd", () => { - const cwd = resolve("/workspace") - const result = toolCallFromPart("bash", { command: "ls", workdir: "rel" }, cwd) - expect(result.locations).toEqual([{ path: resolve(cwd, "rel") }]) - }) -}) - -describe("non-bash tool output fencing", () => { - it("fences opencode-style MCP tool output (exa_web_search_exa, no mcp__ prefix) to prevent markdown injection", () => { - const output = "# Title\nsome result\n## Section\ncontent" - const result = toolResultFromPart("exa_web_search_exa", {}, output, false) - const text = (result.content[0] as any).content.text - expect(text).toMatch(/^```\n/) - expect(text).toContain(output) - expect(text).toMatch(/```$/) - expect(result.rawOutput).toEqual({ stdout: output }) - }) - - it("fences default-branch success output (grep/webfetch/todowrite/unknown) in a plain code block", () => { - for (const tool of ["grep", "webfetch", "todowrite", "codesearch", "unknownTool"]) { - const result = toolResultFromPart(tool, {}, "plain text", false) - const text = (result.content[0] as any).content.text - expect(text).toMatch(/^```\n/) - expect(text).toContain("plain text") - expect(text).toMatch(/\n```$/) - } - }) - - it("drops content on read/list success (title + locations already carry the UX) but preserves rawOutput", () => { - for (const tool of ["read", "list"]) { - const result = toolResultFromPart(tool, {}, "plain text", false) - expect(result.content).toEqual([]) - // unparseable wrapper → falls back to stdout - expect(result.rawOutput).toEqual({ stdout: "plain text" }) - } - }) - - it("fences read/list error output so the user sees why it failed", () => { - for (const tool of ["read", "list"]) { - const result = toolResultFromPart(tool, { filePath: "/missing" }, "ENOENT: no such file", true) - const text = (result.content[0] as any).content.text - expect(text).toMatch(/^```\n/) - expect(text).toContain("ENOENT: no such file") - expect(text).toMatch(/\n```$/) - expect(result.rawOutput).toEqual({ stderr: "ENOENT: no such file" }) - } - }) - - it("parses read file wrapper into structured rawOutput with end footer", () => { - const output = - "/src/a.ts\nfile\n\n1: line one\n2: line two\n\n(End of file - total 2 lines)\n" - const result = toolResultFromPart("read", { filePath: "/src/a.ts" }, output, false) - expect(result.content).toEqual([]) - expect(result.rawOutput).toEqual({ - path: "/src/a.ts", - type: "file", - content: "1: line one\n2: line two", - truncation: { kind: "end", total: 2 }, - }) - }) - - it("parses read file wrapper with 'more' truncation footer", () => { - const output = - "/src/big.ts\nfile\n\n1: a\n2: b\n\n(Showing lines 1-2 of 500. Use offset=3 to continue.)\n" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ - path: "/src/big.ts", - type: "file", - content: "1: a\n2: b", - truncation: { kind: "more", from: 1, to: 2, total: 500, next: 3 }, - }) - }) - - it("parses read file wrapper with 'cut' truncation footer", () => { - const output = - "/src/huge.bin\nfile\n\n1: a\n2: b\n\n(Output capped at 256KB. Showing lines 1-2. Use offset=3 to continue.)\n" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ - path: "/src/huge.bin", - type: "file", - content: "1: a\n2: b", - truncation: { kind: "cut", maxBytes: "256KB", from: 1, to: 2, next: 3 }, - }) - }) - - it("parses read directory wrapper with full footer", () => { - const output = - "/src\ndirectory\n\na.ts\nb.ts\n(2 entries)\n" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ - path: "/src", - type: "directory", - content: "a.ts\nb.ts", - truncation: { kind: "dir_full", total: 2 }, - }) - }) - - it("parses read directory wrapper with partial footer", () => { - const output = - "/src\ndirectory\n\na.ts\nb.ts\n(Showing 2 of 100 entries. Use 'offset' parameter to read beyond entry 2)\n" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ - path: "/src", - type: "directory", - content: "a.ts\nb.ts", - truncation: { kind: "dir_partial", shown: 2, total: 100, next: 2 }, - }) - }) - - it("parses read wrapper with trailing system-reminder", () => { - const output = - "/src/a.ts\nfile\n\n1: x\n\n(End of file - total 1 lines)\n\n\n\nfollow project conventions\n" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ - path: "/src/a.ts", - type: "file", - content: "1: x", - truncation: { kind: "end", total: 1 }, - systemReminder: "follow project conventions", - }) - }) - - it("tolerates literal inside the file body via greedy extraction", () => { - const inner = "1: prose mentioning inline" - const output = - `/src/md.md\nfile\n\n${inner}\n\n(End of file - total 1 lines)\n` - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ - path: "/src/md.md", - type: "file", - content: inner, - truncation: { kind: "end", total: 1 }, - }) - }) - - it("falls back to stdout on malformed wrapper (unexpected tail)", () => { - const output = - "/src/a.ts\nfile\n\n1: x\n\nunexpected trailing text" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ stdout: output }) - }) - - it("falls back to stdout when envelope does not match", () => { - const output = "/src/a.tsfile\n\nmissing newline\n" - const result = toolResultFromPart("read", {}, output, false) - expect(result.rawOutput).toEqual({ stdout: output }) - }) - - it("parses skill wrapper with files into structured rawOutput", () => { - const output = [ - '', - "# Skill: Frontend", - "", - "Do UI things.", - "", - "Base directory for this skill: file:///skills/frontend", - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", - "", - "", - "/skills/frontend/scripts/build.sh", - "/skills/frontend/reference/guide.md", - "", - "", - ].join("\n") - const result = toolResultFromPart("skill", {}, output, false) - expect(result.content).toEqual([]) - expect(result.rawOutput).toEqual({ - name: "Frontend", - markdown: "Do UI things.", - baseDir: "file:///skills/frontend", - files: ["/skills/frontend/scripts/build.sh", "/skills/frontend/reference/guide.md"], - }) - }) - - it("parses skill wrapper with empty files list", () => { - const output = [ - '', - "# Skill: Bare", - "", - "body", - "", - "Base directory for this skill: file:///skills/bare", - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", - "", - "", - "", - "", - "", - ].join("\n") - const result = toolResultFromPart("skill", {}, output, false) - expect(result.rawOutput).toEqual({ - name: "Bare", - markdown: "body", - baseDir: "file:///skills/bare", - files: [], - }) - }) - - it("parses skill wrapper with multi-paragraph markdown body", () => { - const output = [ - '', - "# Skill: Deep", - "", - "Para one.", - "", - "Para two with `code`.", - "", - "Base directory for this skill: file:///x", - "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.", - "Note: file list is sampled.", - "", - "", - "", - "", - "", - ].join("\n") - const result = toolResultFromPart("skill", {}, output, false) - expect((result.rawOutput as any).markdown).toBe("Para one.\n\nPara two with `code`.") - }) - - it("falls back to stdout on malformed skill wrapper", () => { - const output = '\n# Skill: X\n\nbroken — no closing tag' - const result = toolResultFromPart("skill", {}, output, false) - expect(result.rawOutput).toEqual({ stdout: output }) - }) - - it("fences skill error output so the user sees why it failed", () => { - const output = 'Skill "Missing" not found. Available skills: Frontend' - const result = toolResultFromPart("skill", {}, output, true) - const text = (result.content[0] as any).content.text - expect(text).toContain(output) - expect(result.rawOutput).toEqual({ stderr: output }) - }) - - it("parses task wrapper into structured rawOutput", () => { - const output = [ - "task_id: ses_abc123 (for resuming to continue this task if needed)", - "", - "", - "Investigation complete. Found 3 issues in auth.ts.", - "", - ].join("\n") - const result = toolResultFromPart("task", { description: "investigate auth" }, output, false) - expect(result.content).toEqual([]) - expect(result.rawOutput).toEqual({ - taskId: "ses_abc123", - result: "Investigation complete. Found 3 issues in auth.ts.", - }) - }) - - it("parses task wrapper with multiline result preserving internal newlines", () => { - const output = [ - "task_id: ses_xyz (for resuming to continue this task if needed)", - "", - "", - "Line one", - "", - "Line three", - "", - ].join("\n") - const result = toolResultFromPart("task", {}, output, false) - expect((result.rawOutput as any).result).toBe("Line one\n\nLine three") - }) - - it("parses task wrapper with empty result", () => { - const output = [ - "task_id: ses_empty (for resuming to continue this task if needed)", - "", - "", - "", - "", - ].join("\n") - const result = toolResultFromPart("task", {}, output, false) - expect(result.rawOutput).toEqual({ taskId: "ses_empty", result: "" }) - }) - - it("falls back to stdout on malformed task wrapper", () => { - const output = "no task_id prefix\n\nhi\n" - const result = toolResultFromPart("task", {}, output, false) - expect(result.rawOutput).toEqual({ stdout: output }) - }) - - it("fences task error output so the user sees why it failed", () => { - const output = "Task aborted by user" - const result = toolResultFromPart("task", {}, output, true) - const text = (result.content[0] as any).content.text - expect(text).toContain(output) - expect(result.rawOutput).toEqual({ stderr: output }) - }) - - it("preserves widened fence when output contains triple-backticks", () => { - const output = "before\n```\nnested\n```\nafter" - const result = toolResultFromPart("unknown", {}, output, false) - const text = (result.content[0] as any).content.text - expect(text.startsWith("````\n")).toBe(true) - expect(text.endsWith("\n````")).toBe(true) - }) -}) - -describe("permissionDisplayInfo", () => { - it("formats edit permission from lowercase metadata keys", () => { - const info = permissionDisplayInfo( - "edit", - { filepath: "/abs/foo.ts", diff: "--- a\n+++ b\n" }, - CWD, - ) - expect(info.title).toBe("Edit /abs/foo.ts") - expect(info.kind).toBe("edit") - expect(info.locations).toEqual([{ path: "/abs/foo.ts" }]) - expect(info.content).toHaveLength(1) - }) - - it("formats bash permission with description fallback", () => { - const info = permissionDisplayInfo("bash", { command: "rm -rf /", description: "Nuke" }, CWD) - expect(info.title).toBe("Nuke") - expect(info.kind).toBe("execute") - }) - - it("formats webfetch permission", () => { - const info = permissionDisplayInfo("webfetch", { url: "https://example.com" }, CWD) - expect(info.title).toBe("Fetch https://example.com") - expect(info.kind).toBe("fetch") - }) - - it("falls back to permission name on unknown type", () => { - const info = permissionDisplayInfo("doom_loop", {}, CWD) - expect(info.title).toBe("doom_loop") - expect(info.kind).toBe("other") - }) - - it("resolves relative edit filepath against cwd", () => { - const cwd = resolve("/ws") - const info = permissionDisplayInfo("edit", { filepath: "rel/foo.ts" }, cwd) - expect(info.locations).toEqual([{ path: resolve(cwd, "rel/foo.ts") }]) - }) -}) diff --git a/packages/opencode/test/acp/tool.test.ts b/packages/opencode/test/acp/tool.test.ts index e7ad1c1b1638..151af382557d 100644 --- a/packages/opencode/test/acp/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -11,8 +11,8 @@ import { describe("acp tool conversion", () => { test("maps OpenCode tool ids to ACP tool kinds", () => { - expect(toToolKind("bash")).toBe("execute") - expect(toToolKind("shell")).toBe("execute") + expect(toToolKind("bash")).toBe("other") + expect(toToolKind("shell")).toBe("other") expect(toToolKind("webfetch")).toBe("fetch") expect(toToolKind("edit")).toBe("edit") expect(toToolKind("apply_patch")).toBe("edit") @@ -37,6 +37,10 @@ describe("acp tool conversion", () => { expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([ { path: "/tmp/outside" }, ]) + expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }]) + expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([ + { path: "/workspace/subdir" }, + ]) expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([]) expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([]) }) From 4d910ba402b301611da15d551dab6332eebecffd Mon Sep 17 00:00:00 2001 From: Mert Can Demir Date: Wed, 10 Jun 2026 18:53:56 +0300 Subject: [PATCH 7/7] test(acp): align permission tool kind expectation with bash mapping --- packages/opencode/test/acp/permission.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index 966ff6ff1e14..ce6aa6f7a1e6 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -153,7 +153,7 @@ describe("acp permissions", () => { status: "pending", title: "bash", rawInput: { command: "printf hello" }, - kind: "execute", + kind: "other", locations: [], }, options: [