Skip to content

Commit def6dbf

Browse files
committed
fix(tui): consult stripped name for activation on prefixed runner miss
A harness-namespaced call (default.<stripped>) misses the registry under its original name even when <stripped> was activated. Check isActivated with the stripped form too so an activated-but-unmounted tool reports reconnecting instead of unknown tool. Miss-branch strings still use the original name; never-activated misses keep the exact unknown-tool string. Adds keeper tests: dotted registration wins over bare suffix, prefixed activated miss reports reconnecting under the original name, gate-reject on a normalized call reports the stripped name.
1 parent fc7f226 commit def6dbf

2 files changed

Lines changed: 83 additions & 8 deletions

File tree

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,64 @@ describe("harness namespace prefix", () => {
254254
expect(result.isError).toBe(true);
255255
expect(result.content).toBe("unknown tool: default.mcp__gone__tool");
256256
});
257+
258+
test("an exact dotted registration wins over the bare suffix (anti-misrouting)", async () => {
259+
const runner = createDynamicToolRunner([
260+
stringTool("mcp__acme__do", "bare"),
261+
stringTool("default.mcp__acme__do", "dotted"),
262+
]);
263+
runner.setCallGate(() => true);
264+
265+
const result = await runner.run(
266+
{ id: "1", name: "default.mcp__acme__do", arguments: {} },
267+
new AbortController().signal,
268+
);
269+
270+
expect(result.isError).toBeUndefined();
271+
expect(result.content).toBe("dotted");
272+
});
273+
274+
test("a prefixed miss for an activated-but-unmounted stripped tool reports reconnecting under the original name", async () => {
275+
const runner = createDynamicToolRunner([
276+
stringTool("read_file", "core"),
277+
stringTool("mcp__acme__do", "blind-result"),
278+
]);
279+
const activated = new Set(["mcp__acme__do"]);
280+
runner.setCallGate((name) => name === "read_file" || activated.has(name), {
281+
isActivated: (name) => activated.has(name),
282+
});
283+
284+
// The server dropped between search and call, so the bare tool left the
285+
// registry; the model still emits the harness-namespaced form.
286+
runner.removeTools(["mcp__acme__do"]);
287+
const result = await runner.run(
288+
{ id: "1", name: "default.mcp__acme__do", arguments: {} },
289+
new AbortController().signal,
290+
);
291+
292+
expect(result.isError).toBe(true);
293+
expect(result.content).toContain("default.mcp__acme__do");
294+
expect(result.content).toContain("reconnecting");
295+
expect(result.content).toContain("Retry the call shortly");
296+
});
297+
298+
test("a normalized call rejected by the gate reports the stripped name", async () => {
299+
const runner = createDynamicToolRunner([
300+
stringTool("read_file", "core"),
301+
stringTool("mcp__acme__do", "blind-result"),
302+
]);
303+
runner.setCallGate((name) => name === "read_file");
304+
305+
const result = await runner.run(
306+
{ id: "1", name: "default.mcp__acme__do", arguments: {} },
307+
new AbortController().signal,
308+
);
309+
310+
expect(result.isError).toBe(true);
311+
expect(result.content).toContain("mcp__acme__do");
312+
expect(result.content).toContain("tool_search");
313+
expect(result.content).not.toContain("default.mcp__acme__do");
314+
});
257315
});
258316

259317
describe("terminal control stripping", () => {

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,6 @@ export function createDynamicToolRunner(
9292
byName.has(name),
9393
);
9494
if (resolved === undefined) {
95-
return {
96-
callId: call.id,
97-
content: `unknown tool: ${call.name}`,
98-
isError: true,
99-
};
100-
}
101-
const found = byName.get(resolved);
102-
if (found === undefined) {
10395
// The name was promoted (gate-activated) but the registry no longer
10496
// holds it — the server dropped between search and call. Say so: the
10597
// model already has the schema from the tool_search card and only
@@ -114,6 +106,31 @@ export function createDynamicToolRunner(
114106
isError: true,
115107
};
116108
}
109+
// A harness-namespaced call (`default.<stripped>`) misses the registry
110+
// under its original name even though the model was shown `<stripped>`:
111+
// consult activation with the stripped form too, but keep the original
112+
// name in the message so the transcript matches what the model emitted.
113+
const dot = call.name.indexOf(".");
114+
if (dot > 0) {
115+
const stripped = call.name.slice(dot + 1);
116+
if (stripped.length > 0 && isActivated?.(stripped) === true) {
117+
return {
118+
callId: call.id,
119+
content:
120+
`Error: ${call.name} is not currently available - its server may ` +
121+
`still be reconnecting. Retry the call shortly.`,
122+
isError: true,
123+
};
124+
}
125+
}
126+
return {
127+
callId: call.id,
128+
content: `unknown tool: ${call.name}`,
129+
isError: true,
130+
};
131+
}
132+
const found = byName.get(resolved);
133+
if (found === undefined) {
117134
return {
118135
callId: call.id,
119136
content: `unknown tool: ${call.name}`,

0 commit comments

Comments
 (0)