Skip to content

Commit ab3afcd

Browse files
fix(tui): report reconnecting instead of unknown tool for promoted-but-unmounted calls (#1082)
* fix(tui): report reconnecting instead of unknown tool for promoted-but-unmounted calls Summary: the live call gate now distinguishes a registry miss on a gate-activated (promoted) name — its server dropped between search and call — from a never-activated miss. Activated misses return a reconnecting/retry-shortly error; plain misses keep the exact unknown tool string. Both production gates (TUI session, exec) pass the activation predicate; the wire hold-off machinery is untouched. Verification: bun test on dynamic-tool-runner, assemble-runtime, tool-search, exec runner suites plus src/tui/runner (87 pass, 0 fail); oxfmt/oxlint clean on changed files; tsc shows only the pre-existing vendor semver error (proven on pristine HEAD via stash). * fix(tui): clear critic nits on reconnecting-error wording * fix(tui): normalize harness namespace prefix in runner dispatch Strip a single leading <segment>. prefix on registry miss and retry the lookup, so harness-namespaced calls such as default.mcp__linear__list_releases dispatch through the gate. Miss paths keep the exact reconnecting and unknown-tool strings with the original name. * 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 af36a07 commit ab3afcd

4 files changed

Lines changed: 189 additions & 2 deletions

File tree

src/exec/runner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
846846
createExecToolCallGate(isAdvertised, {
847847
isCodex: isCodexProviderName(config.providerName),
848848
}),
849+
// Same promoted-but-unmounted contract as the TUI gate: an activated
850+
// name missing from the registry errors toward retry (see run() in
851+
// DynamicToolRunner).
852+
{ isActivated: (name) => activatedToolNames.has(name) },
849853
);
850854

851855
const { directorHolder, buildAgent } = assembleChatAgent({

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

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,140 @@ describe("mangled dispatch names", () => {
180180
});
181181
});
182182

183+
describe("activated-but-unmounted registry miss", () => {
184+
test("a gate-activated name missing from the registry reports reconnecting, not unknown tool", async () => {
185+
const runner = createDynamicToolRunner([
186+
stringTool("read_file", "core"),
187+
stringTool("mcp__acme__do", "blind-result"),
188+
]);
189+
const activated = new Set(["mcp__acme__do"]);
190+
runner.setCallGate((name) => name === "read_file" || activated.has(name), {
191+
isActivated: (name) => activated.has(name),
192+
});
193+
194+
// The tool was promoted (gate open) but its server disconnected before the
195+
// call, so removeTools dropped it from the registry mid-window.
196+
runner.removeTools(["mcp__acme__do"]);
197+
const result = await runner.run(
198+
{ id: "1", name: "mcp__acme__do", arguments: {} },
199+
new AbortController().signal,
200+
);
201+
202+
expect(result.isError).toBe(true);
203+
expect(result.content).toContain("mcp__acme__do");
204+
expect(result.content).toContain("reconnecting");
205+
expect(result.content).toContain("Retry the call shortly");
206+
expect(result.content).not.toBe("unknown tool: mcp__acme__do");
207+
});
208+
209+
test("a never-activated miss keeps the exact unknown tool string", async () => {
210+
const runner = createDynamicToolRunner([stringTool("read_file", "core")]);
211+
runner.setCallGate((name) => name === "read_file", {
212+
isActivated: () => false,
213+
});
214+
215+
const result = await runner.run(
216+
{ id: "1", name: "mcp__gone__tool", arguments: {} },
217+
new AbortController().signal,
218+
);
219+
220+
expect(result.isError).toBe(true);
221+
expect(result.content).toBe("unknown tool: mcp__gone__tool");
222+
});
223+
});
224+
225+
describe("harness namespace prefix", () => {
226+
test("a default.-prefixed call dispatches the registered bare tool", async () => {
227+
const runner = createDynamicToolRunner([
228+
stringTool("read_file", "core"),
229+
stringTool("mcp__acme__do", "blind-result"),
230+
]);
231+
const advertised = new Set(["read_file", "mcp__acme__do"]);
232+
runner.setCallGate((name) => advertised.has(name));
233+
234+
const result = await runner.run(
235+
{ id: "1", name: "default.mcp__acme__do", arguments: {} },
236+
new AbortController().signal,
237+
);
238+
239+
expect(result.isError).toBeUndefined();
240+
expect(result.content).toBe("blind-result");
241+
});
242+
243+
test("a prefixed miss keeps the exact unknown tool string with the original name", async () => {
244+
const runner = createDynamicToolRunner([stringTool("read_file", "core")]);
245+
runner.setCallGate((name) => name === "read_file", {
246+
isActivated: () => false,
247+
});
248+
249+
const result = await runner.run(
250+
{ id: "1", name: "default.mcp__gone__tool", arguments: {} },
251+
new AbortController().signal,
252+
);
253+
254+
expect(result.isError).toBe(true);
255+
expect(result.content).toBe("unknown tool: default.mcp__gone__tool");
256+
});
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+
});
315+
});
316+
183317
describe("terminal control stripping", () => {
184318
test("strips escape sequences from any tool's result, including MCP", async () => {
185319
const payload = "before\x1b]52;c;ZXZpbA==\x07\x1b[31mred\x1b[0m\x07after";

src/tui/dynamic-tool-runner.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,18 @@ export type DynamicToolRunner = AgentToolRunner & {
3131
* the current wire (built-in prefix + pinned + activated) with an error
3232
* pointing at tool_search, instead of silently dispatching. Without a gate
3333
* every registered tool stays dispatchable — sub-agent runners never set one.
34+
*
35+
* `options.isActivated` is the promotion side of the gate: a name the model
36+
* was already shown (tool_search activated it) that is absent from the
37+
* registry — its server disconnected or the snapshot rebuilt under it —
38+
* reports "not currently available, server may be reconnecting, retry
39+
* shortly" instead of the bare unknown-tool string. Names never activated
40+
* keep the exact unknown-tool string.
3441
*/
35-
setCallGate(isCallable: (name: string) => boolean): void;
42+
setCallGate(
43+
isCallable: (name: string) => boolean,
44+
options?: { isActivated?: (name: string) => boolean },
45+
): void;
3646
};
3747

3848
export function createDynamicToolRunner(
@@ -41,6 +51,7 @@ export function createDynamicToolRunner(
4151
): DynamicToolRunner {
4252
const byName = new Map<string, AgentTool>();
4353
let callGate: ((name: string) => boolean) | undefined;
54+
let isActivated: ((name: string) => boolean) | undefined;
4455

4556
const addTools = (tools: AgentTool[]): void => {
4657
const incoming = new Set<string>();
@@ -69,14 +80,49 @@ export function createDynamicToolRunner(
6980
addTools,
7081
removeTools,
7182
currentDefinitions,
72-
setCallGate(isCallable: (name: string) => boolean): void {
83+
setCallGate(
84+
isCallable: (name: string) => boolean,
85+
options?: { isActivated?: (name: string) => boolean },
86+
): void {
7387
callGate = isCallable;
88+
isActivated = options?.isActivated;
7489
},
7590
async run(call: ToolCall, signal: AbortSignal): Promise<ToolResult> {
7691
const resolved = resolveRegisteredToolName(call.name, (name) =>
7792
byName.has(name),
7893
);
7994
if (resolved === undefined) {
95+
// The name was promoted (gate-activated) but the registry no longer
96+
// holds it — the server dropped between search and call. Say so: the
97+
// model already has the schema from the tool_search card and only
98+
// needs to retry, not re-search. A name never activated keeps the
99+
// exact unknown-tool string.
100+
if (isActivated?.(call.name) === true) {
101+
return {
102+
callId: call.id,
103+
content:
104+
`Error: ${call.name} is not currently available - its server may ` +
105+
`still be reconnecting. Retry the call shortly.`,
106+
isError: true,
107+
};
108+
}
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+
}
80126
return {
81127
callId: call.id,
82128
content: `unknown tool: ${call.name}`,

src/tui/runner/session.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,9 @@ export async function assembleTUISession(
498498
]);
499499
toolset.dynamicRunner.setCallGate(
500500
(name) => unadvertisedCallable.has(name) || isAdvertised(name),
501+
// A promoted-but-unmounted name (server dropped between search and call)
502+
// reports reconnecting instead of bare unknown-tool at the call gate.
503+
{ isActivated: (name) => activatedToolNames.has(name) },
501504
);
502505

503506
// Reload, interrupt, compaction continuation, and proxy deliver share one queue

0 commit comments

Comments
 (0)