|
| 1 | +import { describe, expect, it } from "bun:test" |
| 2 | +import { toolCallFromPart, toolResultFromPart } from "../../src/acp/tool-format" |
| 3 | + |
| 4 | +describe("toolCallFromPart", () => { |
| 5 | + describe("bash/shell/terminal", () => { |
| 6 | + it("formats bash command with description", () => { |
| 7 | + const result = toolCallFromPart("bash", { command: "ls -la", description: "List files", cwd: "/home" }) |
| 8 | + expect(result.title).toBe("List files") |
| 9 | + expect(result.kind).toBe("other") |
| 10 | + expect(result.locations).toEqual([{ path: "/home" }]) |
| 11 | + expect(result.rawInput).toEqual({ command: "ls -la", description: "List files", cwd: "/home" }) |
| 12 | + }) |
| 13 | + |
| 14 | + it("falls back to command when no description", () => { |
| 15 | + const result = toolCallFromPart("shell", { command: "npm install" }) |
| 16 | + expect(result.title).toBe("npm install") |
| 17 | + }) |
| 18 | + |
| 19 | + it("normalizes mcp__acp__ prefix", () => { |
| 20 | + const result = toolCallFromPart("mcp__acp__bash", { command: "echo hi" }) |
| 21 | + expect(result.title).toBe("echo hi") |
| 22 | + }) |
| 23 | + }) |
| 24 | + |
| 25 | + describe("bashoutput", () => { |
| 26 | + it("returns Tail Logs title", () => { |
| 27 | + const result = toolCallFromPart("bashoutput", {}) |
| 28 | + expect(result.title).toBe("Tail Logs") |
| 29 | + expect(result.kind).toBe("execute") |
| 30 | + }) |
| 31 | + }) |
| 32 | + |
| 33 | + describe("read/view", () => { |
| 34 | + it("formats read with file path", () => { |
| 35 | + const result = toolCallFromPart("read", { filePath: "/src/index.ts" }) |
| 36 | + expect(result.title).toBe("Read /src/index.ts") |
| 37 | + expect(result.kind).toBe("read") |
| 38 | + expect(result.locations).toEqual([{ path: "/src/index.ts" }]) |
| 39 | + }) |
| 40 | + |
| 41 | + it("uses 1-based line numbers when offset is provided", () => { |
| 42 | + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) |
| 43 | + expect(result.locations).toEqual([{ path: "/src/index.ts", line: 11 }]) |
| 44 | + }) |
| 45 | + |
| 46 | + it("omits line key when offset is zero", () => { |
| 47 | + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 0 }) |
| 48 | + expect(result.locations).toEqual([{ path: "/src/index.ts" }]) |
| 49 | + }) |
| 50 | + |
| 51 | + it("includes line range suffix", () => { |
| 52 | + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10, limit: 20 }) |
| 53 | + expect(result.title).toBe("Read /src/index.ts (11 - 30)") |
| 54 | + }) |
| 55 | + |
| 56 | + it("includes from-line suffix", () => { |
| 57 | + const result = toolCallFromPart("read", { filePath: "/src/index.ts", offset: 10 }) |
| 58 | + expect(result.title).toBe("Read /src/index.ts (from line 11)") |
| 59 | + }) |
| 60 | + |
| 61 | + it("falls back when no file path", () => { |
| 62 | + const result = toolCallFromPart("read", {}) |
| 63 | + expect(result.title).toBe("Read File") |
| 64 | + expect(result.locations).toEqual([]) |
| 65 | + }) |
| 66 | + }) |
| 67 | + |
| 68 | + describe("edit/str_replace", () => { |
| 69 | + it("formats edit with diff content", () => { |
| 70 | + const result = toolCallFromPart("edit", { |
| 71 | + filePath: "/src/app.ts", |
| 72 | + oldString: "foo", |
| 73 | + newString: "bar", |
| 74 | + }) |
| 75 | + expect(result.title).toBe("Edit `/src/app.ts`") |
| 76 | + expect(result.kind).toBe("edit") |
| 77 | + expect(result.content).toEqual([{ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }]) |
| 78 | + expect(result.locations).toEqual([{ path: "/src/app.ts" }]) |
| 79 | + }) |
| 80 | + |
| 81 | + it("handles missing file path", () => { |
| 82 | + const result = toolCallFromPart("str_replace", { oldString: "a", newString: "b" }) |
| 83 | + expect(result.title).toBe("Edit") |
| 84 | + expect(result.content).toEqual([]) |
| 85 | + }) |
| 86 | + }) |
| 87 | + |
| 88 | + describe("write/create", () => { |
| 89 | + it("formats write with diff content (null oldText)", () => { |
| 90 | + const result = toolCallFromPart("write", { filePath: "/new.ts", content: "hello" }) |
| 91 | + expect(result.title).toBe("Write /new.ts") |
| 92 | + expect(result.kind).toBe("edit") |
| 93 | + expect(result.content).toEqual([{ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }]) |
| 94 | + }) |
| 95 | + }) |
| 96 | + |
| 97 | + describe("glob/find", () => { |
| 98 | + it("formats glob with path and pattern", () => { |
| 99 | + const result = toolCallFromPart("glob", { path: "/src", pattern: "*.ts" }) |
| 100 | + expect(result.title).toBe("Find `/src` `*.ts`") |
| 101 | + expect(result.kind).toBe("search") |
| 102 | + }) |
| 103 | + |
| 104 | + it("absolutizes relative paths", () => { |
| 105 | + const result = toolCallFromPart("glob", { path: "src", pattern: "*.ts" }) |
| 106 | + expect(result.locations[0].path.startsWith("/")).toBe(true) |
| 107 | + }) |
| 108 | + |
| 109 | + it("handles no path or pattern", () => { |
| 110 | + const result = toolCallFromPart("find", {}) |
| 111 | + expect(result.title).toBe("Find") |
| 112 | + }) |
| 113 | + }) |
| 114 | + |
| 115 | + describe("grep/search", () => { |
| 116 | + it("formats grep with pattern and path", () => { |
| 117 | + const result = toolCallFromPart("grep", { pattern: "TODO", path: "/src" }) |
| 118 | + expect(result.title).toBe('grep "TODO" /src') |
| 119 | + expect(result.kind).toBe("search") |
| 120 | + }) |
| 121 | + |
| 122 | + it("truncates long patterns", () => { |
| 123 | + const long = "a".repeat(50) |
| 124 | + const result = toolCallFromPart("grep", { pattern: long }) |
| 125 | + expect(result.title.length).toBeLessThanOrEqual(40) |
| 126 | + }) |
| 127 | + }) |
| 128 | + |
| 129 | + describe("webfetch/fetch", () => { |
| 130 | + it("formats fetch with url", () => { |
| 131 | + const result = toolCallFromPart("webfetch", { url: "https://example.com", prompt: "get title" }) |
| 132 | + expect(result.title).toBe("Fetch https://example.com") |
| 133 | + expect(result.kind).toBe("fetch") |
| 134 | + expect(result.content).toHaveLength(1) |
| 135 | + }) |
| 136 | + }) |
| 137 | + |
| 138 | + describe("websearch", () => { |
| 139 | + it("formats search with query", () => { |
| 140 | + const result = toolCallFromPart("websearch", { query: "bun test" }) |
| 141 | + expect(result.title).toBe('"bun test"') |
| 142 | + expect(result.kind).toBe("fetch") |
| 143 | + }) |
| 144 | + }) |
| 145 | + |
| 146 | + describe("task", () => { |
| 147 | + it("formats task with description", () => { |
| 148 | + const result = toolCallFromPart("task", { description: "Research APIs", prompt: "find REST patterns" }) |
| 149 | + expect(result.title).toBe("Research APIs") |
| 150 | + expect(result.kind).toBe("think") |
| 151 | + expect(result.content).toHaveLength(1) |
| 152 | + }) |
| 153 | + }) |
| 154 | + |
| 155 | + describe("plan mode", () => { |
| 156 | + it("emits switch_mode kind for plan_enter", () => { |
| 157 | + const result = toolCallFromPart("plan_enter", {}) |
| 158 | + expect(result.title).toBe("Enter Plan Mode") |
| 159 | + expect(result.kind).toBe("switch_mode") |
| 160 | + }) |
| 161 | + |
| 162 | + it("emits switch_mode kind for plan_exit", () => { |
| 163 | + const result = toolCallFromPart("plan_exit", {}) |
| 164 | + expect(result.title).toBe("Exit Plan Mode") |
| 165 | + expect(result.kind).toBe("switch_mode") |
| 166 | + }) |
| 167 | + }) |
| 168 | + |
| 169 | + describe("bash kind pinning", () => { |
| 170 | + it("uses kind 'other' instead of spec 'execute' to avoid Zed blue run-box styling", () => { |
| 171 | + const result = toolCallFromPart("bash", { command: "ls", description: "List files" }) |
| 172 | + expect(result.kind).toBe("other") |
| 173 | + }) |
| 174 | + }) |
| 175 | + |
| 176 | + describe("list", () => { |
| 177 | + it("uses read kind for directory listing", () => { |
| 178 | + const result = toolCallFromPart("list", { path: "/src" }) |
| 179 | + expect(result.kind).toBe("read") |
| 180 | + }) |
| 181 | + }) |
| 182 | + |
| 183 | + describe("default", () => { |
| 184 | + it("falls back to tool name for unknown tools", () => { |
| 185 | + const result = toolCallFromPart("unknownTool", {}) |
| 186 | + expect(result.title).toBe("unknownTool") |
| 187 | + expect(result.kind).toBe("other") |
| 188 | + }) |
| 189 | + |
| 190 | + it("uses description if available", () => { |
| 191 | + const result = toolCallFromPart("custom", { description: "Custom action" }) |
| 192 | + expect(result.title).toBe("Custom action") |
| 193 | + }) |
| 194 | + }) |
| 195 | +}) |
| 196 | + |
| 197 | +describe("toolResultFromPart", () => { |
| 198 | + describe("bash/shell/terminal", () => { |
| 199 | + it("returns stdout for success", () => { |
| 200 | + const result = toolResultFromPart("bash", { command: "ls" }, "file1\nfile2", false) |
| 201 | + expect(result.rawOutput).toEqual({ stdout: "file1\nfile2" }) |
| 202 | + expect(result.content).toHaveLength(1) |
| 203 | + expect(result.content[0]).toEqual({ type: "content", content: { type: "text", text: "file1\nfile2" } }) |
| 204 | + }) |
| 205 | + |
| 206 | + it("returns stderr for error", () => { |
| 207 | + const result = toolResultFromPart("shell", { command: "bad" }, "command not found", true) |
| 208 | + expect(result.rawOutput).toEqual({ stderr: "command not found" }) |
| 209 | + }) |
| 210 | + }) |
| 211 | + |
| 212 | + describe("edit/str_replace", () => { |
| 213 | + it("includes diff content on success", () => { |
| 214 | + const result = toolResultFromPart( |
| 215 | + "edit", |
| 216 | + { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, |
| 217 | + "Applied edit", |
| 218 | + false, |
| 219 | + ) |
| 220 | + expect(result.rawOutput).toEqual({ stdout: "Applied edit" }) |
| 221 | + expect(result.content).toHaveLength(2) |
| 222 | + expect(result.content[1]).toEqual({ type: "diff", path: "/src/app.ts", oldText: "foo", newText: "bar" }) |
| 223 | + }) |
| 224 | + |
| 225 | + it("skips diff content on error", () => { |
| 226 | + const result = toolResultFromPart( |
| 227 | + "edit", |
| 228 | + { filePath: "/src/app.ts", oldString: "foo", newString: "bar" }, |
| 229 | + "old_string not found", |
| 230 | + true, |
| 231 | + ) |
| 232 | + expect(result.content).toHaveLength(1) |
| 233 | + }) |
| 234 | + }) |
| 235 | + |
| 236 | + describe("write/create", () => { |
| 237 | + it("includes diff content with null oldText on success", () => { |
| 238 | + const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Created", false) |
| 239 | + expect(result.content).toHaveLength(2) |
| 240 | + expect(result.content[1]).toEqual({ type: "diff", path: "/new.ts", oldText: null, newText: "hello" }) |
| 241 | + }) |
| 242 | + |
| 243 | + it("skips diff on error", () => { |
| 244 | + const result = toolResultFromPart("write", { filePath: "/new.ts", content: "hello" }, "Permission denied", true) |
| 245 | + expect(result.content).toHaveLength(1) |
| 246 | + }) |
| 247 | + }) |
| 248 | + |
| 249 | + describe("patch/apply_patch", () => { |
| 250 | + it("includes patch text content on success", () => { |
| 251 | + const patch = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new" |
| 252 | + const result = toolResultFromPart("patch", { filePath: "/file", diff: patch }, "Patched", false) |
| 253 | + expect(result.content).toHaveLength(2) |
| 254 | + expect(result.content[1]).toEqual({ type: "content", content: { type: "text", text: patch } }) |
| 255 | + }) |
| 256 | + |
| 257 | + it("skips patch content on error", () => { |
| 258 | + const result = toolResultFromPart("apply_patch", { filePath: "/file", diff: "bad" }, "Failed", true) |
| 259 | + expect(result.content).toHaveLength(1) |
| 260 | + }) |
| 261 | + }) |
| 262 | + |
| 263 | + describe("default", () => { |
| 264 | + it("returns stdout for success", () => { |
| 265 | + const result = toolResultFromPart("unknown", {}, "some output", false) |
| 266 | + expect(result.rawOutput).toEqual({ stdout: "some output" }) |
| 267 | + expect(result.content).toHaveLength(1) |
| 268 | + }) |
| 269 | + |
| 270 | + it("returns stderr for error", () => { |
| 271 | + const result = toolResultFromPart("unknown", {}, "error msg", true) |
| 272 | + expect(result.rawOutput).toEqual({ stderr: "error msg" }) |
| 273 | + }) |
| 274 | + |
| 275 | + it("wraps error output in markdown fence", () => { |
| 276 | + const result = toolResultFromPart("unknown", {}, "some error", true) |
| 277 | + const text = (result.content[0] as any).content.text |
| 278 | + expect(text).toContain("```") |
| 279 | + expect(text).toContain("some error") |
| 280 | + }) |
| 281 | + }) |
| 282 | +}) |
0 commit comments