Skip to content

Commit 52972c4

Browse files
fix(tui): resolve default. and doubled names at tool dispatch (#1086)
* fix(tui): resolve default. and doubled names at tool dispatch Muse Spark calls default.mcp__* and name.name while the catalog is unprefixed. Lookup aliases at run, not on the wire. * fix(tui): undouble dotted catalog names at midpoint A first-dot split cannot recover a catalog name that itself contains a dot. Doubling is name.name, so the split belongs at the midpoint.
1 parent 290c1e8 commit 52972c4

4 files changed

Lines changed: 202 additions & 8 deletions

File tree

‎src/tui/dynamic-tool-runner.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,92 @@ describe("call gate", () => {
9494
});
9595
});
9696

97+
describe("mangled dispatch names", () => {
98+
const catalog = "mcp__linear__get_release";
99+
100+
test("strips a leading default. prefix at call time without advertising it", async () => {
101+
const runner = createDynamicToolRunner([stringTool(catalog, "ok")]);
102+
103+
expect(runner.currentDefinitions().map((d) => d.name)).toEqual([catalog]);
104+
expect(runner.currentDefinitions().map((d) => d.name)).not.toContain(
105+
`default.${catalog}`,
106+
);
107+
108+
const result = await runner.run(
109+
{ id: "1", name: `default.${catalog}`, arguments: {} },
110+
new AbortController().signal,
111+
);
112+
expect(result.content).toBe("ok");
113+
expect(result.isError).toBeUndefined();
114+
});
115+
116+
test("resolves a duplicated name.name suffix when both halves match a known tool", async () => {
117+
const runner = createDynamicToolRunner([stringTool(catalog, "ok")]);
118+
119+
const result = await runner.run(
120+
{ id: "1", name: `${catalog}.${catalog}`, arguments: {} },
121+
new AbortController().signal,
122+
);
123+
expect(result.content).toBe("ok");
124+
expect(result.isError).toBeUndefined();
125+
});
126+
127+
test("resolves default. plus a duplicated suffix without advertising either alias", async () => {
128+
const runner = createDynamicToolRunner([stringTool(catalog, "ok")]);
129+
const names = runner.currentDefinitions().map((d) => d.name);
130+
expect(names).toEqual([catalog]);
131+
expect(names).not.toContain(`default.${catalog}`);
132+
expect(names).not.toContain(`${catalog}.${catalog}`);
133+
134+
const result = await runner.run(
135+
{
136+
id: "1",
137+
name: `default.${catalog}.${catalog}`,
138+
arguments: {},
139+
},
140+
new AbortController().signal,
141+
);
142+
expect(result.content).toBe("ok");
143+
expect(result.isError).toBeUndefined();
144+
});
145+
146+
test("bare default stays unknown", async () => {
147+
const runner = createDynamicToolRunner([stringTool(catalog, "ok")]);
148+
149+
const result = await runner.run(
150+
{ id: "1", name: "default", arguments: {} },
151+
new AbortController().signal,
152+
);
153+
expect(result.isError).toBe(true);
154+
expect(result.content).toBe("unknown tool: default");
155+
});
156+
157+
test("a prefixed name still honors the call gate on the catalog name", async () => {
158+
const runner = createDynamicToolRunner([
159+
stringTool("read_file", "core"),
160+
stringTool(catalog, "ok"),
161+
]);
162+
const advertised = new Set(["read_file"]);
163+
runner.setCallGate((name) => advertised.has(name));
164+
165+
const blocked = await runner.run(
166+
{ id: "1", name: `default.${catalog}`, arguments: {} },
167+
new AbortController().signal,
168+
);
169+
expect(blocked.isError).toBe(true);
170+
expect(blocked.content).toContain(catalog);
171+
expect(blocked.content).toContain("tool_search");
172+
173+
advertised.add(catalog);
174+
const result = await runner.run(
175+
{ id: "2", name: `default.${catalog}`, arguments: {} },
176+
new AbortController().signal,
177+
);
178+
expect(result.content).toBe("ok");
179+
expect(result.isError).toBeUndefined();
180+
});
181+
});
182+
97183
describe("terminal control stripping", () => {
98184
test("strips escape sequences from any tool's result, including MCP", async () => {
99185
const payload = "before\x1b]52;c;ZXZpbA==\x07\x1b[31mred\x1b[0m\x07after";

‎src/tui/dynamic-tool-runner.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
runWithToolExecutionWatchdog,
1111
type ToolWatchdogConfig,
1212
} from "./tool-execution-watchdog.js";
13+
import { resolveRegisteredToolName } from "./resolve-registered-tool-name.js";
1314
import { stripTerminalControlSequences } from "../util/control-char-strip.js";
1415

1516
// A tool runner whose set of tools can grow after construction. The static
@@ -72,7 +73,17 @@ export function createDynamicToolRunner(
7273
callGate = isCallable;
7374
},
7475
async run(call: ToolCall, signal: AbortSignal): Promise<ToolResult> {
75-
const found = byName.get(call.name);
76+
const resolved = resolveRegisteredToolName(call.name, (name) =>
77+
byName.has(name),
78+
);
79+
if (resolved === undefined) {
80+
return {
81+
callId: call.id,
82+
content: `unknown tool: ${call.name}`,
83+
isError: true,
84+
};
85+
}
86+
const found = byName.get(resolved);
7687
if (found === undefined) {
7788
return {
7889
callId: call.id,
@@ -85,30 +96,35 @@ export function createDynamicToolRunner(
8596
// or a name the model emitted unaided) must fail loudly with a route back
8697
// to tool_search — silently dispatching it leaves the transcript claiming
8798
// a call the next infer's wire does not declare.
88-
if (callGate !== undefined && !callGate(call.name)) {
99+
if (callGate !== undefined && !callGate(resolved)) {
89100
return {
90101
callId: call.id,
91102
content:
92-
`Error: ${call.name} is not in the currently advertised tool list. ` +
103+
`Error: ${resolved} is not in the currently advertised tool list. ` +
93104
`Call tool_search to activate it, then retry the call.`,
94105
isError: true,
95106
};
96107
}
108+
const dispatchCall =
109+
resolved === call.name ? call : { ...call, name: resolved };
97110
const executionTimeoutMs = resolveToolExecutionTimeoutMs(
98111
watchdogConfig,
99-
call,
112+
dispatchCall,
100113
);
101114
const waitForApproval = resolveWaitForApproval(watchdogConfig);
102115
const result = await runWithToolExecutionWatchdog(
103-
call,
116+
dispatchCall,
104117
signal,
105118
executionTimeoutMs,
106119
async (budgetSignal) => {
107120
try {
108121
if (found.kind === "full")
109-
return await found.handler(call, budgetSignal);
110-
const text = await found.handler(call.arguments, budgetSignal);
111-
return { callId: call.id, content: text };
122+
return await found.handler(dispatchCall, budgetSignal);
123+
const text = await found.handler(
124+
dispatchCall.arguments,
125+
budgetSignal,
126+
);
127+
return { callId: dispatchCall.id, content: text };
112128
} catch (err) {
113129
return {
114130
callId: call.id,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { resolveRegisteredToolName } from "./resolve-registered-tool-name.js";
3+
4+
const catalog = "mcp__linear__get_release";
5+
const known = new Set([catalog, "read_file"]);
6+
const isKnown = (name: string) => known.has(name);
7+
8+
describe("resolveRegisteredToolName", () => {
9+
test("returns an exact catalog hit", () => {
10+
expect(resolveRegisteredToolName(catalog, isKnown)).toBe(catalog);
11+
});
12+
13+
test("strips a leading default. prefix onto a known tool", () => {
14+
expect(resolveRegisteredToolName(`default.${catalog}`, isKnown)).toBe(
15+
catalog,
16+
);
17+
});
18+
19+
test("resolves a duplicated name.name suffix when both halves are known", () => {
20+
expect(resolveRegisteredToolName(`${catalog}.${catalog}`, isKnown)).toBe(
21+
catalog,
22+
);
23+
});
24+
25+
test("strips default. then a duplicated suffix", () => {
26+
expect(
27+
resolveRegisteredToolName(`default.${catalog}.${catalog}`, isKnown),
28+
).toBe(catalog);
29+
});
30+
31+
test("bare default stays unknown", () => {
32+
expect(resolveRegisteredToolName("default", isKnown)).toBeUndefined();
33+
});
34+
35+
test("default. with nothing after it stays unknown", () => {
36+
expect(resolveRegisteredToolName("default.", isKnown)).toBeUndefined();
37+
});
38+
39+
test("a prefix onto an unknown name stays unknown", () => {
40+
expect(
41+
resolveRegisteredToolName("default.mcp__linear__missing", isKnown),
42+
).toBeUndefined();
43+
});
44+
45+
test("unequal halves stay unknown even if one half is known", () => {
46+
expect(
47+
resolveRegisteredToolName(`${catalog}.read_file`, isKnown),
48+
).toBeUndefined();
49+
});
50+
51+
test("equal halves that are not a known tool stay unknown", () => {
52+
expect(resolveRegisteredToolName("ghost.ghost", isKnown)).toBeUndefined();
53+
});
54+
55+
test("resolves a doubled catalog name that itself contains a dot", () => {
56+
const dotted = "mcp__foo.bar__baz";
57+
const isDottedKnown = (name: string) => name === dotted;
58+
expect(
59+
resolveRegisteredToolName(`${dotted}.${dotted}`, isDottedKnown),
60+
).toBe(dotted);
61+
});
62+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
const DEFAULT_PREFIX = "default.";
2+
3+
// Muse Spark emits `default.mcp__*` and duplicated `name.name`; catalog keys
4+
// are the unprefixed names. Lookup-only — do not register aliases on the wire.
5+
export function resolveRegisteredToolName(
6+
requested: string,
7+
isKnown: (name: string) => boolean,
8+
): string | undefined {
9+
if (isKnown(requested)) return requested;
10+
11+
if (requested.startsWith(DEFAULT_PREFIX)) {
12+
const stripped = requested.slice(DEFAULT_PREFIX.length);
13+
if (stripped.length === 0) return undefined;
14+
if (isKnown(stripped)) return stripped;
15+
const undoubled = undoubledKnownName(stripped, isKnown);
16+
if (undoubled !== undefined) return undoubled;
17+
}
18+
19+
return undoubledKnownName(requested, isKnown);
20+
}
21+
22+
function undoubledKnownName(
23+
requested: string,
24+
isKnown: (name: string) => boolean,
25+
): string | undefined {
26+
if (requested.length < 3 || requested.length % 2 === 0) return undefined;
27+
const name = requested.slice(0, (requested.length - 1) / 2);
28+
if (requested !== `${name}.${name}` || !isKnown(name)) return undefined;
29+
return name;
30+
}

0 commit comments

Comments
 (0)