diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index bce705d..b5cbff7 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -378,6 +378,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { // #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; + // #60: args live on tool_execution_start (the SDK's end event carries none) — capture per + // toolCallId so extractTouchedFiles + the journal see the real args on real runs. + const pendingToolArgs = new Map(); // #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" @@ -445,13 +448,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { aborted = true; void session.abort(); } + } else if (e.type === "tool_execution_start") { + pendingToolArgs.set((e as { toolCallId?: string }).toolCallId ?? "", (e as { args?: unknown }).args); // #60 } else if (e.type === "tool_execution_end") { // #49: track mutated files for the structured partial-result report. toolCallCount += 1; // #61 + const toolCallId = (e as { toolCallId?: string }).toolCallId ?? ""; + // #60: real args from the start event; `?? e.args` keeps hand-rolled fakes that put args + // on the end event working (the SDK never does). + const args = pendingToolArgs.get(toolCallId) ?? (e as { args?: unknown }).args; + pendingToolArgs.delete(toolCallId); 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); + for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", args)) filesTouched.add(f); try { - opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx)); + opts.runLog?.append(runId, buildToolEvent((e as any).toolName, args, (e as any).result, (e as any).isError ?? false, turnIdx)); } catch { /* best-effort */ } } // #23: liveness heartbeat — update the run record on meaningful events so the fleet widget @@ -595,6 +605,8 @@ async function finishRun( error, // #61: executed-tool count (the zero-work premature-return signal, post-hoc too). toolCallCount, + // #60: what the run mutated (was only on the SpawnResult; the durable journal lacked it). + filesTouched, }); } catch { /* best-effort: journal is the index, not the product */ } // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index bea8f12..6e1ec05 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -37,6 +37,8 @@ export interface RunEndedEvent { error?: string; /** #61: executed-tool count (the zero-work premature-return signal, post-hoc too). */ toolCallCount?: number; + /** #60: file paths the run mutated (post-hoc — was previously SpawnResult-only). */ + filesTouched?: string[]; } export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent; diff --git a/test/spawn-subagent-runlog.test.mts b/test/spawn-subagent-runlog.test.mts index 4986eb7..c7642ca 100644 --- a/test/spawn-subagent-runlog.test.mts +++ b/test/spawn-subagent-runlog.test.mts @@ -270,3 +270,65 @@ test("#61 follow-up: claude tool_use input feeds filesTouched (Edit block path e }); deepStrictEqual(res.filesTouched, ["/repo/src/a.ts"], "claude Edit blocks contribute to filesTouched (#49 parity)"); }); + +test("#60: REAL SDK shape — args live on tool_execution_start (end has none); capture restores filesTouched + journal args", async () => { + // The pi SDK emits tool_execution_end WITHOUT args ({toolCallId, toolName, result, isError}); + // only tool_execution_start carries args. The old code read args off the END event (always + // undefined on real runs) — filesTouched stayed empty and the journal serialized args as "". + const handlers: Array<(e: any) => void> = []; + const realShapeChild: ChildSession = { + prompt: async () => { + for (const h of handlers) h({ type: "session_init", backendSessionId: "s60" }); + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + // exact pi SDK shape: start carries args, end does NOT + for (const h of handlers) h({ type: "tool_execution_start", toolCallId: "t1", toolName: "edit", args: { path: "/repo/src/real.ts", edits: [] } }); + for (const h of handlers) h({ type: "tool_execution_end", toolCallId: "t1", toolName: "edit", 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: realShapeChild, 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"); + deepStrictEqual(res.filesTouched, ["/repo/src/real.ts"], "filesTouched populated from the START event's args"); + const toolEvent = log.replay(res.runId).find((e) => e.type === "tool") as any; + ok(typeof toolEvent.args === "string" && toolEvent.args.includes("/repo/src/real.ts"), `journal tool event carries real args (was ""): ${toolEvent.args}`); +}); + +test("#60: filesTouched is journaled on run:ended when the turn budget cuts the run", async () => { + const handlers: Array<(e: any) => void> = []; + const budgetChild: ChildSession = { + prompt: async () => { + for (const h of handlers) h({ type: "session_init", backendSessionId: "s60b" }); + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "tool_execution_start", toolCallId: "t1", toolName: "write", args: { path: "/repo/src/cut.ts" } }); + for (const h of handlers) h({ type: "tool_execution_end", toolCallId: "t1", toolName: "write", result: "ok", isError: false }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0, message: {} as any, toolResults: [] }); + for (const h of handlers) h({ type: "turn_start", turnIndex: 1 }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 1, message: {} as any, toolResults: [] }); + }, + subscribe: (h: any) => { handlers.push(h); return () => {}; }, + abort: async () => {}, dispose: () => {}, + }; + const factory: ChildSessionFactory = { create: async () => ({ session: budgetChild, model: "m" }) }; + const log = new RunLog(logDir); + const h = harness(factory, log); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, runLog: log, maxTurns: 1, + registry: h.registry, todoSync: h.todoSync, runRegistry: h.runRegistry, lock: h.lock, backendRegistry: h.backendRegistry, + parentModel: PARENT, parentCwd: tmpDir, + }); + strictEqual(res.status, "failed", "turn budget exhausted"); + ok((res.filesTouched ?? []).includes("/repo/src/cut.ts"), `SpawnResult.filesTouched: ${res.filesTouched}`); + const ended = log.replay(res.runId).at(-1) as any; + strictEqual(ended.type, "run:ended"); + deepStrictEqual(ended.filesTouched, ["/repo/src/cut.ts"], "run:ended carries filesTouched (the #60 journal gap)"); +});