From d8a830be8f19023ca06d934885919265b6447b89 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 29 Aug 2026 11:41:15 +0700 Subject: [PATCH 1/3] feat(spawn): flag zero-tool-call completed runs as likely premature returns #61 dogfood finding: an implementer returned after a single planning statement with zero tool calls and no turn-budget exhaustion, looking like a normal (terse) completion. The controller had to inspect the repo to discover no work was done. - spawnSubagent counts executed tool calls and surfaces toolCallCount on SpawnResult + the run:ended journal event. - The subagent tool prefixes a completed zero-tool result with a '[FLEET] zero-tool-call run - likely a premature return' warning and exposes details.toolCallCount, so the controller verifies (git status/log) before trusting the result. Failed runs are unchanged (they already carry an error). Surfacing-only by design: a zero-tool run made no side effects, so the retry/auto-retry question stays with the controller. --- src/engine/spawnSubagent.ts | 17 +++++++--- src/runtime/run-log.ts | 2 ++ src/tools/subagent.ts | 12 ++++++- test/spawn-subagent-runlog.test.mts | 50 +++++++++++++++++++++++++++++ test/subagent-tool.test.mts | 36 +++++++++++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index 9caf60f..45e4232 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -182,6 +182,9 @@ export interface SpawnResult { * or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows * whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */ reachedSummary?: boolean; + /** #61: number of executed tool calls. A "completed" run with 0 is the premature-return + * shape (the child narrated and ended without acting) — the tool flags it in the result. */ + toolCallCount?: number; } /** #49: extract file paths a tool event touched, for the structured partial-result report. @@ -363,6 +366,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { // a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work. const filesTouched = new Set(); let sawAssistantAfterLastTool = true; // no tools yet = trivially "reached a summary" + // #61: executed-tool count — a "completed" run with zero tool calls is the premature-return + // shape (read/narrate/end without acting); the tool flags it so the controller verifies. + let toolCallCount = 0; // #23: liveness — classify events into a short, content-free class string for the widget. // Names the tool (safe — tool name is not args/result) so the operator sees "what's happening" @@ -432,6 +438,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { } } else if (e.type === "tool_execution_end") { // #49: track mutated files for the structured partial-result report. + toolCallCount += 1; // #61 sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", (e as { args?: unknown }).args)) filesTouched.add(f); try { @@ -524,7 +531,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { status = "completed"; } - return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary); + return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary, toolCallCount); } finally { // #31: a readOnly dispatch never acquired the lock — don't release what it didn't take // (releasing a lock held by another concurrent write dispatch would corrupt serialization). @@ -547,7 +554,7 @@ async function finishRun( status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined, error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0, retryable?: boolean, - filesTouched?: string[], reachedSummary?: boolean, + filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0, ): Promise { if (finalizedRunIds.has(runId)) { // Already finalized — return the existing registry record's result without re-appending. @@ -557,7 +564,7 @@ async function finishRun( runId, todoId, agent: agentName, model, durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt, tokenTotal, costTotal, contextTokens, error, retryable, - filesTouched, reachedSummary, + filesTouched, reachedSummary, toolCallCount, }; } finalizedRunIds.add(runId); @@ -577,6 +584,8 @@ async function finishRun( // #59: journal the failure reason — the archived failing runs had run:ended with an empty // resultSummary and no error field, making post-hoc diagnosis from the journal impossible. error, + // #61: executed-tool count (the zero-work premature-return signal, post-hoc too). + toolCallCount, }); } catch { /* best-effort: journal is the index, not the product */ } // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle @@ -595,6 +604,6 @@ async function finishRun( return { status, finalText, runId, todoId, agent: agentName, model, durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable, - filesTouched, reachedSummary, + filesTouched, reachedSummary, toolCallCount, }; } diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index 9c46ea5..bea8f12 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -35,6 +35,8 @@ export interface RunEndedEvent { contextTokens?: number; /** #59: the failure reason on failed runs (post-hoc diagnosability from the journal). */ error?: string; + /** #61: executed-tool count (the zero-work premature-return signal, post-hoc too). */ + toolCallCount?: number; } export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent; diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index 32e2f68..affd6be 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -251,13 +251,23 @@ export function createSubagentTool(deps: SubagentToolDeps) { }; } const isError = finalRes.status === "failed" || finalRes.status === "aborted"; + // #61: a run that "completed" without a single tool call is usually a premature return + // (the child narrated a plan and ended without acting) — flag it in-band so the controller + // verifies (git status/log) instead of trusting a terse planning statement as a completion. + const zeroToolRun = !isError && (finalRes.toolCallCount ?? 0) === 0; + const resultText = isError + ? (finalRes.error ?? finalRes.status) + : zeroToolRun + ? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.\n\n${finalRes.finalText}` + : finalRes.finalText; return { - content: [{ type: "text" as const, text: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }], + content: [{ type: "text" as const, text: resultText }], details: { runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model, status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal, retriedWithModel, filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary, + toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal }, isError, }; diff --git a/test/spawn-subagent-runlog.test.mts b/test/spawn-subagent-runlog.test.mts index fc8c0fd..ba8fc83 100644 --- a/test/spawn-subagent-runlog.test.mts +++ b/test/spawn-subagent-runlog.test.mts @@ -151,3 +151,53 @@ test("#59: run:ended carries the failure reason (error) on failed runs", async ( strictEqual(ended.type, "run:ended"); ok(typeof ended.error === "string" && ended.error.includes("quota exhausted"), `run:ended.error present + meaningful: ${ended.error}`); }); + +test("#61: toolCallCount lands on SpawnResult + run:ended (executed-tool count)", async () => { + const handlers: Array<(e: any) => void> = []; + const toolChild: ChildSession = { + prompt: async () => { + for (const h of handlers) h({ type: "session_init", backendSessionId: "s61" }); + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "tool_execution_end", toolCallId: "t1", toolName: "read", result: "ok", isError: false }); + for (const h of handlers) h({ type: "tool_execution_end", toolCallId: "t2", toolName: "bash", result: "ok", isError: false }); + for (const h of handlers) h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0, message: {} as any, toolResults: [] }); + }, + subscribe: (h: any) => { handlers.push(h); return () => {}; }, + abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { create: async () => ({ session: toolChild, model: "m" }) }; + const log = new RunLog(logDir); + const h = harness(factory, log); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, runLog: log, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: h.backendRegistry, + parentModel: PARENT, parentCwd: tmpDir, + }); + strictEqual(res.status, "completed"); + strictEqual(res.toolCallCount, 2, "SpawnResult.toolCallCount counts executed tools"); + const ended = log.replay(res.runId).at(-1) as any; + strictEqual(ended.toolCallCount, 2, "run:ended carries toolCallCount"); +}); + +test("#61: completed run with ZERO tool calls → toolCallCount 0 (the premature-return signal)", async () => { + const handlers: Array<(e: any) => void> = []; + const narrateOnlyChild: ChildSession = { + prompt: async () => { + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Let me read the files first." }] } }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0, message: {} as any, toolResults: [] }); + }, + subscribe: (h: any) => { handlers.push(h); return () => {}; }, + abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { create: async () => ({ session: narrateOnlyChild, model: "m" }) }; + const h = harness(factory, new RunLog(logDir)); + const res = await spawnSubagent({ + agent: "g", task: "implement a big feature", track: true, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: h.backendRegistry, + parentModel: PARENT, parentCwd: tmpDir, + }); + strictEqual(res.status, "completed"); + strictEqual(res.toolCallCount, 0, "zero executed tools is the #61 degenerate shape"); +}); diff --git a/test/subagent-tool.test.mts b/test/subagent-tool.test.mts index 26820b4..87415a8 100644 --- a/test/subagent-tool.test.mts +++ b/test/subagent-tool.test.mts @@ -564,3 +564,39 @@ test("#58: non-retryable failure (prompt threw) → NO hint regardless of fallba ok(text.includes("child crashed mid-prompt"), `surfaces the real failure: ${text}`); ok(!text.includes("no modelFallback configured"), `no hint on a non-retryable failure: ${text}`); }); + +test("#61: zero-tool completed run → result prefixed with the premature-return warning", async () => { + // The #61 dogfood failure: an implementer returned after one planning statement, zero tool + // calls, looking like a normal (terse) completion. The tool must flag it, not just relay it. + const deps = makeDeps(); // default fakeFactory child: one assistant message_end, no tools + const tool = createSubagentTool(deps); + const out = await tool.execute!("c", { agent: "g", task: "implement a big feature" } as any, new AbortController().signal, () => {}, {} as any); + strictEqual((out.details as any).status, "completed"); + strictEqual((out.details as any).toolCallCount, 0, "details expose the zero-tool count"); + const text = (out.content as any)[0].text as string; + ok(text.includes("[FLEET] zero-tool-call run"), `warning prefix present: ${text.slice(0, 160)}`); + ok(text.includes("premature return"), `names the failure mode: ${text.slice(0, 160)}`); + ok(text.includes("done"), `original finalText preserved after the warning: ${text}`); +}); + +test("#61: completed run WITH tool calls → no warning prefix, accurate count", async () => { + const handlers: Array<(e: any) => void> = []; + const toolChild = { + prompt: async () => { + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "tool_execution_end", toolCallId: "t1", toolName: "read", result: "ok", isError: false }); + for (const h of handlers) h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0, message: {} as any, toolResults: [] }); + }, + subscribe: (h: any) => { handlers.push(h); return () => {}; }, abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { create: async () => ({ session: toolChild, model: "m" }) }; + const deps = makeDeps(); + const reg = new BackendRegistry(); + reg.register({ id: "pi", factory, available: () => true, versionInfo: () => null, hookParity: PI_HOOK_PARITY }); + deps.backendRegistry = reg; + const tool = createSubagentTool(deps); + const out = await tool.execute!("c", { agent: "g", task: "x" } as any, new AbortController().signal, () => {}, {} as any); + strictEqual((out.details as any).toolCallCount, 1); + strictEqual((out.content as any)[0].text, "done", "no prefix on a run that did work"); +}); From ff618aca40ecff79d57f9eb953245ac49f224a3d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 29 Aug 2026 11:49:20 +0700 Subject: [PATCH 2/3] fix(claude): emit tool_execution_end per tool_use block (review CRITICAL) Review on PR #71 caught that the claude backend never emitted tool events (mapClaudeEvent flattened tool_use into message content), so the #61 zero-tool-call signal would have falsely flagged every completed claude run as a premature return. - mapClaudeEvents(line) returns ALL events a CC line implies: an assistant message with tool_use blocks yields message_end + one tool_execution_end per block (toolCallId/toolName from the block). mapClaudeEvent stays as a first-event compat wrapper (detector). - ClaudeChildSession.onLine iterates the mapped events. - ChildSessionEvent documents the tool-event fields (toolCallId/ toolName/result/isError) that consumers previously cast for. Tests: mapper events (multi-block, text-only, compat wrapper) + a claude-path counting test through spawnSubagent (mapped CC line -> toolCallCount >= 2). --- src/backend/claude-events.ts | 45 ++++++++++++++++++++--------- src/backend/claude-session.ts | 20 ++++++------- src/engine/spawnSubagent.ts | 6 ++++ test/claude-events.test.mts | 44 ++++++++++++++++++++++++++-- test/spawn-subagent-runlog.test.mts | 37 ++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/backend/claude-events.ts b/src/backend/claude-events.ts index 7789030..9e10677 100644 --- a/src/backend/claude-events.ts +++ b/src/backend/claude-events.ts @@ -1,39 +1,58 @@ -// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2). +// src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent(s) (SPEC-3 §4.2). // Returns null for: filtered echoes (our own user writes), unknown types, malformed lines. // The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need). import type { ChildSessionEvent } from "../engine/spawnSubagent.ts"; -interface CCMessage { role?: string; content?: Array<{ type: string; text?: string }>; usage?: Record; } +interface CCContentBlock { type: string; text?: string; id?: string; name?: string; } +interface CCMessage { role?: string; content?: CCContentBlock[]; usage?: Record; } interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; } -export function mapClaudeEvent(line: string): ChildSessionEvent | null { +/** Map one line to ALL the ChildSessionEvents it implies. An assistant message carrying tool_use + * blocks yields the message_end PLUS one tool_execution_end per block (#61: the engine's + * zero-tool-call premature-return signal must count claude children too — without this, every + * completed claude run counted 0 tools and was falsely flagged). The per-block end event fires + * at message time, not per-tool completion (CC stream-json gives no finer granularity) — right + * enough for "did the child act", which is what the count is for. */ +export function mapClaudeEvents(line: string): ChildSessionEvent[] { let ev: CCEvent; try { ev = JSON.parse(line) as CCEvent; } catch { - return null; // malformed line — resilient + return []; // malformed line — resilient } switch (ev.type) { - case "system": + case "system": { if (ev.subtype === "init" && typeof ev.session_id === "string") { - return { type: "session_init", backendSessionId: ev.session_id }; + return [{ type: "session_init", backendSessionId: ev.session_id }]; } - return null; + return []; + } case "assistant": { const msg = ev.message; - if (!msg) return null; + if (!msg) return []; const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text })); const usage = msg.usage as { cost?: { total?: number } } | undefined; - return { type: "message_end", message: { role: msg.role ?? "assistant", content, usage } }; + const events: ChildSessionEvent[] = [{ type: "message_end", message: { role: msg.role ?? "assistant", content, usage } }]; + for (const block of msg.content ?? []) { + if (block.type === "tool_use") { + events.push({ type: "tool_execution_end", toolCallId: block.id ?? "", toolName: block.name ?? "unknown", result: "", isError: false }); + } + } + return events; } case "result": // turn boundary (success or error_max_turns) → turn_end drives the budget - return { type: "turn_end" }; + return [{ type: "turn_end" }]; case "error": - return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } }; + return [{ type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } }]; case "user": - return null; // echo of our own stdin write — filtered + return []; // echo of our own stdin write — filtered default: - return null; // unknown — forward-compat, caller logs at debug + return []; // unknown — forward-compat, caller logs at debug } +} + +/** Single-event compat wrapper (claude-detector + existing consumers read one event per line). */ +export function mapClaudeEvent(line: string): ChildSessionEvent | null { + return mapClaudeEvents(line)[0] ?? null; } \ No newline at end of file diff --git a/src/backend/claude-session.ts b/src/backend/claude-session.ts index 72923a4..cf5a088 100644 --- a/src/backend/claude-session.ts +++ b/src/backend/claude-session.ts @@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { createInterface } from "node:readline"; import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts"; -import { mapClaudeEvent } from "./claude-events.ts"; +import { mapClaudeEvents } from "./claude-events.ts"; import type { ResumeStore } from "./resume-store.ts"; export class ClaudeChildSession implements ChildSession { @@ -24,16 +24,16 @@ export class ClaudeChildSession implements ChildSession { } private onLine(line: string): void { - const ev = mapClaudeEvent(line); - if (!ev) return; - if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) { - this.initCaptured = true; - this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId); + for (const ev of mapClaudeEvents(line)) { + if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) { + this.initCaptured = true; + this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId); + } + if (ev.type === "turn_end" || ev.type === "error") { + if (this.turnResolve) { this.turnResolve(); this.turnResolve = null; } + } + for (const h of this.handlers) h(ev); } - if (ev.type === "turn_end" || ev.type === "error") { - if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); } - } - for (const h of this.handlers) h(ev); } async prompt(text: string): Promise { diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index 45e4232..3efa674 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -44,6 +44,12 @@ export interface ChildSessionEvent { }; /** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */ backendSessionId?: string; + /** tool_execution_end fields (pi SDK native; claude mapper synthesizes from tool_use blocks, #61). + * Consumers currently cast — these make the real shape type-visible. */ + toolCallId?: string; + toolName?: string; + result?: unknown; + isError?: boolean; } export interface ChildSession { diff --git a/test/claude-events.test.mts b/test/claude-events.test.mts index 991e42e..c0699e8 100644 --- a/test/claude-events.test.mts +++ b/test/claude-events.test.mts @@ -1,6 +1,6 @@ import { test } from "node:test"; import { strictEqual, ok } from "node:assert"; -import { mapClaudeEvent } from "../src/backend/claude-events.ts"; +import { mapClaudeEvent, mapClaudeEvents } from "../src/backend/claude-events.ts"; test("init event → session_init with backendSessionId", () => { const e = mapClaudeEvent(JSON.stringify({ type: "system", subtype: "init", session_id: "abc-123", cwd: "/x", version: "1.0.0" })); @@ -45,4 +45,44 @@ test("error event → error event forwarded", () => { const e = mapClaudeEvent(JSON.stringify({ type: "error", error: { type: "api_error", message: "boom" } })); ok(e); strictEqual(e!.type, "error"); -}); \ No newline at end of file +}); +test("#61: assistant message with tool_use blocks → message_end + one tool_execution_end per block", () => { + // The claude backend never emitted tool events (tool_use was flattened into message content), + // so the #61 zero-tool-call signal counted every claude run as 0 tools — a systematic false + // "premature return" flag on genuine completions. + const line = JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "text", text: "Running the checks" }, + { type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "pnpm test" } }, + { type: "tool_use", id: "toolu_2", name: "Read", input: { file_path: "/x" } }, + ], + }, + }); + const events = mapClaudeEvents(line); + strictEqual(events.length, 3, "message_end + 2 tool_execution_end"); + strictEqual(events[0]!.type, "message_end", "message_end first (finalText/usage handling unchanged)"); + strictEqual(events[1]!.type, "tool_execution_end"); + strictEqual((events[1] as any).toolName, "Bash"); + strictEqual((events[1] as any).toolCallId, "toolu_1"); + strictEqual(events[2]!.type, "tool_execution_end"); + strictEqual((events[2] as any).toolName, "Read"); +}); + +test("#61: mapClaudeEvent stays first-event-only (detector + existing consumers unchanged)", () => { + const line = JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "Bash", input: {} }] }, + }); + const e = mapClaudeEvent(line); + ok(e); + strictEqual(e!.type, "message_end", "wrapper returns the message_end"); +}); + +test("#61: assistant text-only message → no tool events (single message_end)", () => { + const events = mapClaudeEvents(JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "done" }] } })); + strictEqual(events.length, 1); + strictEqual(events[0]!.type, "message_end"); +}); diff --git a/test/spawn-subagent-runlog.test.mts b/test/spawn-subagent-runlog.test.mts index ba8fc83..78cab04 100644 --- a/test/spawn-subagent-runlog.test.mts +++ b/test/spawn-subagent-runlog.test.mts @@ -201,3 +201,40 @@ test("#61: completed run with ZERO tool calls → toolCallCount 0 (the premature strictEqual(res.status, "completed"); strictEqual(res.toolCallCount, 0, "zero executed tools is the #61 degenerate shape"); }); + +test("#61: claude-path counting — mapped CC tool_use lines yield toolCallCount > 0 (no false zero-work flag)", async () => { + // End-to-end over the claude event mapping: a claude child's assistant NDJSON line, mapped + // through mapClaudeEvents, must produce tool events the engine counts — a completed claude + // run that DID work must never be flagged as a zero-tool premature return. + const { mapClaudeEvents } = await import("../src/backend/claude-events.ts"); + const ccLine = JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "text", text: "Editing now" }, + { type: "tool_use", id: "toolu_a", name: "Edit", input: { file_path: "/a.ts" } }, + { type: "tool_use", id: "toolu_b", name: "Bash", input: { command: "pnpm test" } }, + ], + }, + }); + const handlers: Array<(e: any) => void> = []; + const claudeChild: ChildSession = { + prompt: async () => { + for (const ev of mapClaudeEvents(ccLine)) for (const h of handlers) h(ev); + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0, message: {} as any, toolResults: [] }); + }, + subscribe: (h: any) => { handlers.push(h); return () => {}; }, + abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { create: async () => ({ session: claudeChild, model: "claude" }) }; + const h = harness(factory, new RunLog(logDir)); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: h.backendRegistry, + parentModel: PARENT, parentCwd: tmpDir, + }); + strictEqual(res.status, "completed"); + ok((res.toolCallCount ?? 0) >= 2, `claude tools counted (no false premature-return): ${res.toolCallCount}`); +}); From 9f3efd38acf75a55c908f16f700f0a07f26dd02a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 29 Aug 2026 11:52:00 +0700 Subject: [PATCH 3/3] fix(claude): pass tool_use input as args on synthesized tool events (review NIT) Claude Edit/Write blocks now carry their input through to the engine, so claude children contribute to the #49 filesTouched report (extractTouched Files reads the file_path arg). Previously claude was blind to #49. --- src/backend/claude-events.ts | 4 ++-- src/engine/spawnSubagent.ts | 7 ++++-- test/spawn-subagent-runlog.test.mts | 34 ++++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/backend/claude-events.ts b/src/backend/claude-events.ts index 9e10677..521a30d 100644 --- a/src/backend/claude-events.ts +++ b/src/backend/claude-events.ts @@ -3,7 +3,7 @@ // The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need). import type { ChildSessionEvent } from "../engine/spawnSubagent.ts"; -interface CCContentBlock { type: string; text?: string; id?: string; name?: string; } +interface CCContentBlock { type: string; text?: string; id?: string; name?: string; input?: Record; } interface CCMessage { role?: string; content?: CCContentBlock[]; usage?: Record; } interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; } @@ -35,7 +35,7 @@ export function mapClaudeEvents(line: string): ChildSessionEvent[] { const events: ChildSessionEvent[] = [{ type: "message_end", message: { role: msg.role ?? "assistant", content, usage } }]; for (const block of msg.content ?? []) { if (block.type === "tool_use") { - events.push({ type: "tool_execution_end", toolCallId: block.id ?? "", toolName: block.name ?? "unknown", result: "", isError: false }); + events.push({ type: "tool_execution_end", toolCallId: block.id ?? "", toolName: block.name ?? "unknown", args: block.input, result: "", isError: false }); } } return events; diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index 3efa674..bce705d 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -48,6 +48,7 @@ export interface ChildSessionEvent { * Consumers currently cast — these make the real shape type-visible. */ toolCallId?: string; toolName?: string; + args?: unknown; result?: unknown; isError?: boolean; } @@ -199,11 +200,13 @@ export interface SpawnResult { function extractTouchedFiles(toolName: string, args: unknown): string[] { if (!args || typeof args !== "object") return []; const a = args as Record; - if (toolName === "edit" || toolName === "write") { + // Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit"). + const name = toolName.toLowerCase(); + if (name === "edit" || name === "write") { const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined; return p ? [p] : []; } - if (toolName === "bash") { + if (name === "bash") { const cmd = typeof a.command === "string" ? a.command : undefined; if (!cmd) return []; const out: string[] = []; diff --git a/test/spawn-subagent-runlog.test.mts b/test/spawn-subagent-runlog.test.mts index 78cab04..4986eb7 100644 --- a/test/spawn-subagent-runlog.test.mts +++ b/test/spawn-subagent-runlog.test.mts @@ -1,6 +1,6 @@ // test/spawn-subagent-runlog.test.mts import { test, beforeEach, afterEach } from "node:test"; -import { strictEqual, ok } from "node:assert"; +import { strictEqual, ok, deepStrictEqual } from "node:assert"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -238,3 +238,35 @@ test("#61: claude-path counting — mapped CC tool_use lines yield toolCallCount strictEqual(res.status, "completed"); ok((res.toolCallCount ?? 0) >= 2, `claude tools counted (no false premature-return): ${res.toolCallCount}`); }); + +test("#61 follow-up: claude tool_use input feeds filesTouched (Edit block path extracted)", async () => { + const { mapClaudeEvents } = await import("../src/backend/claude-events.ts"); + const ccLine = JSON.stringify({ + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "text", text: "Editing" }, + { type: "tool_use", id: "toolu_e", name: "Edit", input: { file_path: "/repo/src/a.ts" } }, + ], + }, + }); + const handlers: Array<(e: any) => void> = []; + const claudeChild: ChildSession = { + prompt: async () => { + for (const ev of mapClaudeEvents(ccLine)) for (const h of handlers) h(ev); + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0, message: {} as any, toolResults: [] }); + }, + subscribe: (h: any) => { handlers.push(h); return () => {}; }, + abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { create: async () => ({ session: claudeChild, model: "claude" }) }; + const h = harness(factory, new RunLog(logDir)); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: h.backendRegistry, + parentModel: PARENT, parentCwd: tmpDir, + }); + deepStrictEqual(res.filesTouched, ["/repo/src/a.ts"], "claude Edit blocks contribute to filesTouched (#49 parity)"); +});