Skip to content

Commit 0d67c78

Browse files
committed
refactor(agent): compare tool names through a single helper
Wire-name aliases previously leaked into engine-name comparisons and broke tracking; each new alias required hunting every callsite. Both sides now resolve through canonicalToolName first, so future projections only touch the alias tables.
1 parent 3f41c24 commit 0d67c78

13 files changed

Lines changed: 252 additions & 74 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
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+
});

‎src/agent/canonical-tool-name.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ export function canonicalToolName(requested: string): string {
2020
return engineToolName(baseToolName(requested));
2121
}
2222

23+
// Canonical comparison for tool names arriving on the wire. Either side may
24+
// be an engine id, a profile wire name (read/bash/…), a hidden alias
25+
// (shell/update_plan/wait), or a default./doubled prefixed form — both sides
26+
// resolve through canonicalToolName first, so a rename or per-family
27+
// projection only touches the alias tables, never the callsites. Case
28+
// handling matches engineToolName (alias lookup falls back to lowercase;
29+
// unknown names compare verbatim).
30+
export function isSameTool(a: string, b: string): boolean {
31+
return canonicalToolName(a) === canonicalToolName(b);
32+
}
33+
2334
// Grant coverage across the alias→engine collapse. Comparisons run in native
2435
// key space (both sides resolve onto the engine id first); a raw alias name is
2536
// never compared against a native id. Pure renames

‎src/agent/director.test.ts‎

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,29 +1013,42 @@ describe("open-task termination guard", () => {
10131013
expect(hasInfer(actions)).toBe(false);
10141014
});
10151015

1016-
test("a task change emits the updated task list on the chat event", async () => {
1017-
const director = createChatDirector("base", [], {});
1018-
const actions = actionsArray(
1019-
await director.decide(
1020-
manageTasksEvent("doing"),
1021-
stubReactorState,
1022-
stubReactorCapabilities,
1023-
),
1024-
);
1025-
expect(
1026-
actions.filter(
1027-
(a) =>
1028-
a.type === "emit" &&
1029-
(a as { eventType?: string }).eventType === CHAT_TASKS_CHANGED_EVENT,
1030-
),
1031-
).toEqual([
1032-
{
1033-
type: "emit",
1034-
eventType: CHAT_TASKS_CHANGED_EVENT,
1035-
data: { tasks: [{ id: "t1", title: "work", status: "doing" }] },
1036-
},
1037-
]);
1038-
});
1016+
test.each(["manage_tasks", "todowrite", "update_plan"] as const)(
1017+
"a task change via %s emits the updated task list on the chat event",
1018+
async (name) => {
1019+
const director = createChatDirector("base", [], {});
1020+
const actions = actionsArray(
1021+
await director.decide(
1022+
makeInferenceDoneEvent([
1023+
{
1024+
id: "m",
1025+
name,
1026+
args: {
1027+
action: "create",
1028+
tasks: [{ id: "t1", title: "work", status: "doing" }],
1029+
},
1030+
},
1031+
]),
1032+
stubReactorState,
1033+
stubReactorCapabilities,
1034+
),
1035+
);
1036+
expect(
1037+
actions.filter(
1038+
(a) =>
1039+
a.type === "emit" &&
1040+
(a as { eventType?: string }).eventType ===
1041+
CHAT_TASKS_CHANGED_EVENT,
1042+
),
1043+
).toEqual([
1044+
{
1045+
type: "emit",
1046+
eventType: CHAT_TASKS_CHANGED_EVENT,
1047+
data: { tasks: [{ id: "t1", title: "work", status: "doing" }] },
1048+
},
1049+
]);
1050+
},
1051+
);
10391052

10401053
test("stops nudging and lets the turn end after the cap of content-free attempts", async () => {
10411054
const director = createChatDirector("base", [], {});

‎src/agent/director.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { canonicalToolName } from "./canonical-tool-name.js";
1+
import { isSameTool } from "./canonical-tool-name.js";
22
import {
33
DefaultDirector,
44
type ExtendedInferenceOptions,
@@ -447,7 +447,7 @@ function applyManageTasksToolCall(
447447
tasks: Task[],
448448
block: { name: string; arguments: unknown },
449449
): Task[] | null {
450-
if (canonicalToolName(block.name) !== "manage_tasks") return null;
450+
if (!isSameTool(block.name, "manage_tasks")) return null;
451451
const taskArgs = parseManageTasksArgs(block.arguments);
452452
return taskArgs !== null ? applyManageTasks(tasks, taskArgs) : null;
453453
}
@@ -1174,8 +1174,7 @@ class ChatDirectorImpl extends DefaultDirector {
11741174
}
11751175
for (const block of event.turn.content) {
11761176
if (block.type !== "tool_call") continue;
1177-
const engineName = canonicalToolName(block.name);
1178-
if (engineName === "manage_tasks") {
1177+
if (isSameTool(block.name, "manage_tasks")) {
11791178
const next = applyManageTasksToolCall(this.tasks, block);
11801179
if (next !== null) {
11811180
this.tasks = next;
@@ -1185,18 +1184,21 @@ class ChatDirectorImpl extends DefaultDirector {
11851184
}),
11861185
);
11871186
}
1188-
} else if (engineName === "read_file" || engineName === "edit_file") {
1187+
} else if (
1188+
isSameTool(block.name, "read_file") ||
1189+
isSameTool(block.name, "edit_file")
1190+
) {
11891191
const pathResult = PathArgSchema(block.arguments);
11901192
const path = pathResult instanceof type.errors ? "" : pathResult.path;
11911193
if (isCodeFile(path)) this.lspTriggerCalls.add(block.id);
11921194
}
1193-
if (block.name === "submit_output") {
1195+
if (isSameTool(block.name, "submit_output")) {
11941196
this.workflowCalls.set(block.id, {
11951197
name: block.name,
11961198
args: block.arguments,
11971199
});
11981200
}
1199-
if (engineName === "ask_operator") {
1201+
if (isSameTool(block.name, "ask_operator")) {
12001202
this.askOperatorCalls.add(block.id);
12011203
}
12021204
}

‎src/subagent/poll-exempt.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ describe("isPollOnlyPendingBatch", () => {
3030
).toBe(true);
3131
});
3232

33+
test("gpt wire alias wait is exempt like wait_agents", () => {
34+
expect(
35+
isPollOnlyPendingBatch(
36+
[call("wait")],
37+
[result(waitContent(["running"], true))],
38+
),
39+
).toBe(true);
40+
});
41+
3342
test("live wait statuses without timeout are exempt", () => {
3443
for (const status of ["running", "queued", "awaiting_director"]) {
3544
expect(

‎src/subagent/poll-exempt.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { canonicalToolName } from "../agent/canonical-tool-name.js";
1+
import { isSameTool } from "../agent/canonical-tool-name.js";
22
import { type } from "arktype";
33
import type { ToolCall, ToolResult } from "@intx/types/runtime";
44
import { isLiveWaitStatus, type WaitJSONStatus } from "./lifecycle.js";
@@ -41,7 +41,7 @@ export function isPollOnlyPendingBatch(
4141
return calls.every((call, index) => {
4242
const result = results[index];
4343
if (result === undefined) return false;
44-
if (canonicalToolName(call.name) !== "wait_agents") return false;
44+
if (!isSameTool(call.name, "wait_agents")) return false;
4545
const payload = resultPayload(result);
4646
if (payload === undefined) return false;
4747
return isWaitAgentsPending(payload);

‎src/tui/mcp-view.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* for the pagination cursor.
1616
*/
1717

18-
import { canonicalToolName } from "../agent/canonical-tool-name.js";
18+
import { isSameTool } from "../agent/canonical-tool-name.js";
1919
import {
2020
fg as fgChunk,
2121
bold as boldChunk,
@@ -502,7 +502,7 @@ const USE_SKILL_TOOL = "use_skill";
502502
* future wording) simply does not collapse.
503503
*/
504504
function loadedSkillName(name: string, content: string): string | undefined {
505-
if (canonicalToolName(name) !== USE_SKILL_TOOL) return undefined;
505+
if (!isSameTool(name, USE_SKILL_TOOL)) return undefined;
506506
return /^Skill "([^"]+)"/.exec(content)?.[1];
507507
}
508508

‎src/tui/runtime-bridge.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* later waves can bind real agent APIs). Not the production CLI entry.
77
*/
88

9-
import { canonicalToolName } from "../agent/canonical-tool-name.js";
9+
import { canonicalToolName, isSameTool } from "../agent/canonical-tool-name.js";
1010
import {
1111
cancelItem,
1212
createSessionQueue,
@@ -1009,7 +1009,7 @@ function applyToolCall(
10091009
raw: Extract<BridgeInboundEvent, { type: "tool_call" }>,
10101010
): void {
10111011
const event = { ...raw, name: canonicalToolName(raw.name) };
1012-
if (event.name === MANAGE_TASKS_TOOL_NAME) {
1012+
if (isSameTool(raw.name, MANAGE_TASKS_TOOL_NAME)) {
10131013
// Remembered so the matching result is dropped too — suppressing only the
10141014
// call would leave its result to land as an unpaired row. Checklist lives
10151015
// on the task panel; spawn_agent dispatches paint live transcript rows instead.
@@ -1043,7 +1043,10 @@ function applyToolCall(
10431043
if (bag.turn.blockedGateCount > 0) bag.gatedToolCalls.add(event.callId);
10441044
}
10451045
}
1046-
if (event.callId !== undefined && event.name === SPAWN_AGENT_TOOL_NAME) {
1046+
if (
1047+
event.callId !== undefined &&
1048+
isSameTool(raw.name, SPAWN_AGENT_TOOL_NAME)
1049+
) {
10471050
bag.taskCallIds.add(event.callId);
10481051
bag.spawnProgressRows.set(event.callId, index);
10491052
}
@@ -1084,7 +1087,7 @@ function applyToolResult(
10841087
// spawn_agent's immediate running JSON is not the end of the worker —
10851088
// keep the row in taskCallIds / spawnProgressRows until the session
10861089
// leaves the running set (see syncAgentProgress).
1087-
if (event.name !== SPAWN_AGENT_TOOL_NAME) {
1090+
if (!isSameTool(raw.name, SPAWN_AGENT_TOOL_NAME)) {
10881091
bag.taskCallIds.delete(event.callId);
10891092
bag.spawnProgressRows.delete(event.callId);
10901093
}

‎src/tui/stall-watchdog.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ describe("shouldAbortForStall", () => {
7676
}),
7777
).toBe(false);
7878
});
79+
80+
test.each(["wait_agents", "wait"] as const)(
81+
"in-flight %s is stall-bounded under any wire name",
82+
(currentToolName) => {
83+
// streamingType "tool" isolates the stall-bounded branch: an ordinary
84+
// tool run with this shape is not a stall (see above).
85+
expect(
86+
shouldAbortForStall({
87+
...base,
88+
streamingType: "tool",
89+
currentToolName,
90+
}),
91+
).toBe(true);
92+
},
93+
);
7994
});
8095

8196
// Awaiting the model's next response with no tokens yet — set right after

‎src/tui/stall-watchdog.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { canonicalToolName } from "../agent/canonical-tool-name.js";
1+
import { isSameTool } from "../agent/canonical-tool-name.js";
22
import type { TurnStatus } from "./chrome-state.js";
33

44
// How long the run can be continuously awaiting a response with no new content
@@ -76,8 +76,7 @@ function silentPastThreshold(
7676
*/
7777
function isStallBoundedToolName(name: string | null | undefined): boolean {
7878
if (name === null || name === undefined) return false;
79-
const engine = canonicalToolName(name);
80-
return engine === "wait_agents" || engine === "ask_director";
79+
return isSameTool(name, "wait_agents") || isSameTool(name, "ask_director");
8180
}
8281

8382
function isStallBoundedInFlightTool(args: ShouldAbortForStallArgs): boolean {

0 commit comments

Comments
 (0)