Skip to content

Commit de7e8e2

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 b25bfc2 commit de7e8e2

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
@@ -94,6 +94,48 @@ describe("call gate", () => {
9494
});
9595
});
9696

97+
describe("activated-but-unmounted registry miss", () => {
98+
test("a gate-activated name missing from the registry reports reconnecting, not unknown tool", async () => {
99+
const runner = createDynamicToolRunner([
100+
stringTool("read_file", "core"),
101+
stringTool("mcp__acme__do", "blind-result"),
102+
]);
103+
const activated = new Set(["mcp__acme__do"]);
104+
runner.setCallGate((name) => name === "read_file" || activated.has(name), {
105+
isActivated: (name) => activated.has(name),
106+
});
107+
108+
// The tool was promoted (gate open) but its server disconnected before the
109+
// call, so removeTools dropped it from the registry mid-window.
110+
runner.removeTools(["mcp__acme__do"]);
111+
const result = await runner.run(
112+
{ id: "1", name: "mcp__acme__do", arguments: {} },
113+
new AbortController().signal,
114+
);
115+
116+
expect(result.isError).toBe(true);
117+
expect(result.content).toContain("mcp__acme__do");
118+
expect(result.content).toContain("reconnecting");
119+
expect(result.content).toContain("Retry the call shortly");
120+
expect(result.content).not.toBe("unknown tool: mcp__acme__do");
121+
});
122+
123+
test("a never-activated miss keeps the exact unknown tool string", async () => {
124+
const runner = createDynamicToolRunner([stringTool("read_file", "core")]);
125+
runner.setCallGate((name) => name === "read_file", {
126+
isActivated: () => false,
127+
});
128+
129+
const result = await runner.run(
130+
{ id: "1", name: "mcp__gone__tool", arguments: {} },
131+
new AbortController().signal,
132+
);
133+
134+
expect(result.isError).toBe(true);
135+
expect(result.content).toBe("unknown tool: mcp__gone__tool");
136+
});
137+
});
138+
97139
describe("terminal control stripping", () => {
98140
test("strips escape sequences from any tool's result, including MCP", async () => {
99141
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
@@ -30,8 +30,18 @@ export type DynamicToolRunner = AgentToolRunner & {
3030
* the current wire (built-in prefix + pinned + activated) with an error
3131
* pointing at tool_search, instead of silently dispatching. Without a gate
3232
* every registered tool stays dispatchable — sub-agent runners never set one.
33+
*
34+
* `options.isActivated` is the promotion side of the gate: a name the model
35+
* was already shown (tool_search activated it) that is absent from the
36+
* registry — its server disconnected or the snapshot rebuilt under it —
37+
* reports "not currently available, server may be reconnecting, retry
38+
* shortly" instead of the bare unknown-tool string. Names never activated
39+
* keep the exact unknown-tool string.
3340
*/
34-
setCallGate(isCallable: (name: string) => boolean): void;
41+
setCallGate(
42+
isCallable: (name: string) => boolean,
43+
options?: { isActivated?: (name: string) => boolean },
44+
): void;
3545
};
3646

3747
export function createDynamicToolRunner(
@@ -40,6 +50,7 @@ export function createDynamicToolRunner(
4050
): DynamicToolRunner {
4151
const byName = new Map<string, AgentTool>();
4252
let callGate: ((name: string) => boolean) | undefined;
53+
let isActivated: ((name: string) => boolean) | undefined;
4354

4455
const addTools = (tools: AgentTool[]): void => {
4556
const incoming = new Set<string>();
@@ -68,12 +79,30 @@ export function createDynamicToolRunner(
6879
addTools,
6980
removeTools,
7081
currentDefinitions,
71-
setCallGate(isCallable: (name: string) => boolean): void {
82+
setCallGate(
83+
isCallable: (name: string) => boolean,
84+
options?: { isActivated?: (name: string) => boolean },
85+
): void {
7286
callGate = isCallable;
87+
isActivated = options?.isActivated;
7388
},
7489
async run(call: ToolCall, signal: AbortSignal): Promise<ToolResult> {
7590
const found = byName.get(call.name);
7691
if (found === undefined) {
92+
// The name was promoted (gate-activated) but the registry no longer
93+
// holds it — the server dropped between search and call. Say so: the
94+
// model already has the schema from the tool_search card and only
95+
// needs to retry, not re-search. A name never activated keeps the
96+
// exact unknown-tool string.
97+
if (isActivated?.(call.name) === true) {
98+
return {
99+
callId: call.id,
100+
content:
101+
`Error: ${call.name} is not currently available — its server may ` +
102+
`still be reconnecting. Retry the call shortly.`,
103+
isError: true,
104+
};
105+
}
77106
return {
78107
callId: call.id,
79108
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)