|
| 1 | +import { describe, expect, test } from "bun:test"; |
| 2 | +import { join } from "node:path"; |
| 3 | +import { isSameTool } from "./canonical-tool-name.js"; |
| 4 | + |
| 5 | +describe("isSameTool", () => { |
| 6 | + test.each([ |
| 7 | + // Engine id against itself. |
| 8 | + ["read_file", "read_file"], |
| 9 | + ["manage_tasks", "manage_tasks"], |
| 10 | + // Default-profile wire names the model actually emits. |
| 11 | + ["read", "read_file"], |
| 12 | + ["read_file", "read"], |
| 13 | + ["edit", "edit_file"], |
| 14 | + ["bash", "run_shell"], |
| 15 | + ["run_shell", "bash"], |
| 16 | + ["todowrite", "manage_tasks"], |
| 17 | + ["manage_tasks", "todowrite"], |
| 18 | + // Hidden gpt-profile aliases dispatch onto the same engine. |
| 19 | + ["shell", "run_shell"], |
| 20 | + ["shell", "bash"], |
| 21 | + ["update_plan", "manage_tasks"], |
| 22 | + ["wait", "wait_agents"], |
| 23 | + // Muse Spark default./doubled prefixes collapse first. |
| 24 | + ["default.read", "read_file"], |
| 25 | + ["default.read_file", "read"], |
| 26 | + ["read.read", "read_file"], |
| 27 | + ["default.bash", "run_shell"], |
| 28 | + // Alias lookup falls back to lowercase, matching engineToolName. |
| 29 | + ["READ", "read_file"], |
| 30 | + ["Bash", "run_shell"], |
| 31 | + ["WAIT", "wait_agents"], |
| 32 | + ])("isSameTool(%j, %j) is true", (a, b) => { |
| 33 | + expect(isSameTool(a, b)).toBe(true); |
| 34 | + expect(isSameTool(b, a)).toBe(true); |
| 35 | + }); |
| 36 | + |
| 37 | + test.each([ |
| 38 | + ["read", "edit"], |
| 39 | + ["read_file", "write_file"], |
| 40 | + ["bash", "read"], |
| 41 | + ["todowrite", "wait_agents"], |
| 42 | + ["update_plan", "read_file"], |
| 43 | + ["wait", "run_shell"], |
| 44 | + ["submit_plan", "manage_tasks"], |
| 45 | + ["present", "manage_tasks"], |
| 46 | + ["submit_output", "manage_tasks"], |
| 47 | + ["mcp__acme__read", "read_file"], |
| 48 | + ])("isSameTool(%j, %j) is false", (a, b) => { |
| 49 | + expect(isSameTool(a, b)).toBe(false); |
| 50 | + expect(isSameTool(b, a)).toBe(false); |
| 51 | + }); |
| 52 | +}); |
| 53 | + |
| 54 | +// Callsites that compare incoming wire names must go through isSameTool (or |
| 55 | +// the canonical modules themselves). A new raw `name === "<tool>"` compare in |
| 56 | +// one of these files reintroduces the #1252 breakage on the next wire rename, |
| 57 | +// so this test fails until the callsite is converted. |
| 58 | +describe("tool-name comparison regression guard", () => { |
| 59 | + const CALLSITES = [ |
| 60 | + "src/agent/director.ts", |
| 61 | + "src/subagent/poll-exempt.ts", |
| 62 | + "src/tui/mcp-view.ts", |
| 63 | + "src/tui/runtime-bridge.ts", |
| 64 | + "src/tui/stall-watchdog.ts", |
| 65 | + "src/tui/tool-execution-watchdog.ts", |
| 66 | + "src/tui/turns-to-blocks.ts", |
| 67 | + ]; |
| 68 | + |
| 69 | + const TOOL_LITERALS = new Set([ |
| 70 | + "read_file", |
| 71 | + "write_file", |
| 72 | + "edit_file", |
| 73 | + "delete_file", |
| 74 | + "run_shell", |
| 75 | + "search_files", |
| 76 | + "manage_tasks", |
| 77 | + "use_skill", |
| 78 | + "web_fetch", |
| 79 | + "web_search", |
| 80 | + "ask_operator", |
| 81 | + "ask_director", |
| 82 | + "wait_agents", |
| 83 | + "spawn_agent", |
| 84 | + "submit_output", |
| 85 | + "submit_plan", |
| 86 | + "present", |
| 87 | + "list_agents", |
| 88 | + "read", |
| 89 | + "write", |
| 90 | + "edit", |
| 91 | + "delete", |
| 92 | + "bash", |
| 93 | + "shell", |
| 94 | + "glob", |
| 95 | + "todowrite", |
| 96 | + "skill", |
| 97 | + "webfetch", |
| 98 | + "websearch", |
| 99 | + "question", |
| 100 | + "update_plan", |
| 101 | + "wait", |
| 102 | + ]); |
| 103 | + |
| 104 | + test("no raw tool-name ===/!== compares outside the canonical modules", async () => { |
| 105 | + const root = join(import.meta.dirname, "..", ".."); |
| 106 | + const violations: string[] = []; |
| 107 | + for (const file of CALLSITES) { |
| 108 | + const text = await Bun.file(join(root, file)).text(); |
| 109 | + for (const [index, line] of text.split("\n").entries()) { |
| 110 | + const match = /(?:\?\.name|\.name)\s*(===|!==)\s*(["'])([^"']+)\2/.exec( |
| 111 | + line, |
| 112 | + ); |
| 113 | + if (match?.[3] !== undefined && TOOL_LITERALS.has(match[3])) { |
| 114 | + violations.push(`${file}:${index + 1}: ${line.trim()}`); |
| 115 | + } |
| 116 | + const canonical = |
| 117 | + /canonicalToolName\(([^)]+)\)\s*(===|!==)\s*["']/.exec(line); |
| 118 | + if (canonical !== null) { |
| 119 | + violations.push(`${file}:${index + 1}: ${line.trim()}`); |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + expect(violations).toEqual([]); |
| 124 | + }); |
| 125 | +}); |
0 commit comments