fix(pi-claude-cli): unblock parameterless MCP tool calls in triage

Triage with claude-sonnet-4-6 via pi-claude-cli kept looping on
fn_review_spec calls that were rejected by pi's validator with
"root: must be object". Parameterless MCP tools (schema
{type:"object", properties:{}}) emit zero input_json_delta events,
so partialJson stayed "" and the catch fell through to
finalArgs = "" — a string, which TypeBox's Type.Object({}) rightly
refuses. Default empty partialJson to {} so the call lands.

Also:
- Add a 2-step reminder loop in triage before swapping to the
  fallback planning model — primary models that wrote PROMPT.md
  but forgot fn_review_spec recover from a nudge, no need to pay
  the cold-start tax of a new triage on a different model.
- Inject @runfusion/fusion's own pi extension into dashboard/
  daemon/serve sessions and propagate the path to createFnAgent
  via setHostExtensionPaths so fn_* tools register globally
  without requiring `pi install npm:@runfusion/fusion`.
- Drop the "historical" qualifier from replayed tool labels —
  Claude was reading "TOOL RESULT (historical Read):" as
  "previous session, ignore" and looping on verification.
- Remove subprocess-lifecycle stderr debug logs that landed for
  hang diagnosis — root cause is fixed, the noise can go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-26 00:00:49 -07:00
parent 15149905fd
commit c1a2b6edd7
13 changed files with 269 additions and 89 deletions

View File

@@ -444,6 +444,40 @@ describe("createEventBridge", () => {
expect(event.toolCall.arguments).toEqual({ path: "/foo.ts" });
});
it("emits {} for parameterless MCP tool calls (no input_json_delta)", () => {
// Parameterless MCP tools (e.g. fn_review_spec, schema
// {type:"object", properties:{}}) emit ZERO input_json_delta events.
// Without the empty-partialJson guard, finalArgs would fall through to
// the raw-string fallback ("") and pi's TypeBox validator would reject
// the call with "Validation failed for tool ...: root: must be object".
const bridge = createBridgeWithStart();
bridge.handleEvent({
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: "toolu_01XYZ",
name: "mcp__custom-tools__fn_review_spec",
},
});
// No content_block_delta with input_json_delta — Claude emits none for
// parameterless tools.
stream.push.mockClear();
stream.events.length = 0;
bridge.handleEvent({
type: "content_block_stop",
index: 0,
});
expect(stream.push).toHaveBeenCalledTimes(1);
const event = stream.events[0] as any;
expect(event.type).toBe("toolcall_end");
expect(event.toolCall.arguments).toEqual({});
// The MCP prefix should be stripped: pi sees the bare tool name.
expect(event.toolCall.name).toBe("fn_review_spec");
});
it("tracks multiple tool_use blocks independently by Claude event.index", () => {
const bridge = createBridgeWithStart();

View File

@@ -21,7 +21,7 @@ describe("buildPrompt", () => {
expect(buildPrompt(context)).toBe("ASSISTANT:\nHi there");
});
it("produces 'TOOL RESULT (historical {claudeName}):\\n{content}' for a tool result message", () => {
it("produces 'TOOL RESULT ({claudeName}):\\n{content}' for a tool result message", () => {
const context = {
messages: [
{
@@ -33,7 +33,7 @@ describe("buildPrompt", () => {
} as unknown as any;
// Pi tool name "read" should be mapped to Claude name "Read" in the label
expect(buildPrompt(context)).toBe(
"TOOL RESULT (historical Read):\nfile contents here",
"TOOL RESULT (Read):\nfile contents here",
);
});
@@ -57,7 +57,7 @@ describe("buildPrompt", () => {
"What is in file.ts?",
"ASSISTANT:",
"Let me read that file.",
"TOOL RESULT (historical Read):",
"TOOL RESULT (Read):",
"export const x = 1;",
"USER:",
"Now explain it.",
@@ -109,7 +109,7 @@ describe("buildPrompt", () => {
// Tool name should be mapped from pi "read" to Claude "Read"
// Arg "path" should be mapped from pi format to Claude "file_path"
expect(result).toContain(
'Historical tool call (non-executable): Read args={"file_path":"/file.ts"}',
'[Prior tool call — already executed; result follows in TOOL RESULT (Read):] args={"file_path":"/file.ts"}',
);
});
@@ -158,7 +158,7 @@ describe("buildPrompt", () => {
const result = buildPrompt(context);
// Pi "bash" maps to Claude "Bash"
expect(result).toContain(
"Historical tool call (non-executable): Bash args={}",
"[Prior tool call — already executed; result follows in TOOL RESULT (Bash):] args={}",
);
});
@@ -177,7 +177,7 @@ describe("buildPrompt", () => {
} as unknown as any;
const result = buildPrompt(context);
expect(result).toBe("TOOL RESULT (historical Bash):\nline 1\nline 2");
expect(result).toBe("TOOL RESULT (Bash):\nline 1\nline 2");
});
describe("tool name and argument reverse mapping", () => {
@@ -237,7 +237,7 @@ describe("buildPrompt", () => {
} as unknown as any;
const result = buildPrompt(context);
expect(result).toContain("TOOL RESULT (historical Read):");
expect(result).toContain("TOOL RESULT (Read):");
});
it("prefixes custom (non-built-in) tool names with MCP prefix", () => {
@@ -281,7 +281,8 @@ describe("buildPrompt", () => {
const result = buildPrompt(context);
// String arguments should be serialized as JSON string
expect(result).toContain('Read args="raw string args"');
expect(result).toContain('TOOL RESULT (Read):');
expect(result).toContain('args="raw string args"');
});
});
});
@@ -688,7 +689,7 @@ describe("custom tool history replay", () => {
} as unknown as any;
const result = buildPrompt(context);
expect(result).toContain("TOOL RESULT (historical Read):");
expect(result).toContain("TOOL RESULT (Read):");
expect(result).not.toContain("mcp__custom-tools__");
});
@@ -1019,7 +1020,7 @@ describe("buildResumePrompt", () => {
],
};
const result = buildResumePrompt(context) as string;
expect(result).toContain("TOOL RESULT (historical Read):");
expect(result).toContain("TOOL RESULT (Read):");
expect(result).toContain("file contents here");
expect(result).toContain("Now explain it");
});