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
45 changes: 32 additions & 13 deletions src/backend/claude-events.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>; }
interface CCContentBlock { type: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; }
interface CCMessage { role?: string; content?: CCContentBlock[]; usage?: Record<string, unknown>; }
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", args: block.input, 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;
}
20 changes: 10 additions & 10 deletions src/backend/claude-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<void> {
Expand Down
30 changes: 24 additions & 6 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ 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;
args?: unknown;
result?: unknown;
isError?: boolean;
}

export interface ChildSession {
Expand Down Expand Up @@ -182,6 +189,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.
Expand All @@ -190,11 +200,13 @@ export interface SpawnResult {
function extractTouchedFiles(toolName: string, args: unknown): string[] {
if (!args || typeof args !== "object") return [];
const a = args as Record<string, unknown>;
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[] = [];
Expand Down Expand Up @@ -363,6 +375,9 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
// a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
const filesTouched = new Set<string>();
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"
Expand Down Expand Up @@ -432,6 +447,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
}
} 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 {
Expand Down Expand Up @@ -524,7 +540,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
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).
Expand All @@ -547,7 +563,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<SpawnResult> {
if (finalizedRunIds.has(runId)) {
// Already finalized — return the existing registry record's result without re-appending.
Expand All @@ -557,7 +573,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);
Expand All @@ -577,6 +593,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
Expand All @@ -595,6 +613,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,
};
}
2 changes: 2 additions & 0 deletions src/runtime/run-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 11 additions & 1 deletion src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
44 changes: 42 additions & 2 deletions test/claude-events.test.mts
Original file line number Diff line number Diff line change
@@ -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" }));
Expand Down Expand Up @@ -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");
});
});
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");
});
Loading
Loading