Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
// #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<string, unknown>();

// #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"
Expand Down Expand Up @@ -445,13 +448,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/run-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
62 changes: 62 additions & 0 deletions test/spawn-subagent-runlog.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
});
Loading