Skip to content

Commit ac0aa4d

Browse files
committed
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).
1 parent e096f3b commit ac0aa4d

4 files changed

Lines changed: 78 additions & 2 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,8 @@ 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 (see above).
850+
{ isActivated: (name) => activatedToolNames.has(name) },
849851
);
850852

851853
const { directorHolder, buildAgent } = assembleChatAgent({

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,48 @@ 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+
183225
describe("terminal control stripping", () => {
184226
test("strips escape sequences from any tool's result, including MCP", async () => {
185227
const payload = "before\x1b]52;c;ZXZpbA==\x07\x1b[31mred\x1b[0m\x07after";

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

Lines changed: 31 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,8 +80,12 @@ 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) =>
@@ -85,6 +100,20 @@ export function createDynamicToolRunner(
85100
}
86101
const found = byName.get(resolved);
87102
if (found === undefined) {
103+
// The name was promoted (gate-activated) but the registry no longer
104+
// holds it — the server dropped between search and call. Say so: the
105+
// model already has the schema from the tool_search card and only
106+
// needs to retry, not re-search. A name never activated keeps the
107+
// exact unknown-tool string.
108+
if (isActivated?.(call.name) === true) {
109+
return {
110+
callId: call.id,
111+
content:
112+
`Error: ${call.name} is not currently available — its server may ` +
113+
`still be reconnecting. Retry the call shortly.`,
114+
isError: true,
115+
};
116+
}
88117
return {
89118
callId: call.id,
90119
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)