diff --git a/src/subagent/agent-fleet.ts b/src/subagent/agent-fleet.ts index 2da7c9b91..db12c4d03 100644 --- a/src/subagent/agent-fleet.ts +++ b/src/subagent/agent-fleet.ts @@ -1118,6 +1118,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { const parentTraceId = getCurrentTurnTraceId(); telemetry.capture("subagent_start", { agent_name: agentName }); const startedAt = Date.now(); + let setupEndMs: number | undefined; let settlement: Readonly | undefined; let endFinalized = false; let runInterrupted = false; @@ -1136,6 +1137,7 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { agentName, status: setupFailed ? "failed" : status, durationMs: Date.now() - startedAt, + setupMs: (setupEndMs ?? Date.now()) - startedAt, model: settlement?.model ?? provider.model, stopReason: setupFailed ? "setup_error" @@ -1149,6 +1151,13 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { reasoning_tokens: 0, tool_call_count: 0, tool_error_count: 0, + tool_read_count: 0, + tool_write_count: 0, + tool_shell_count: 0, + tool_search_count: 0, + tool_agent_count: 0, + tool_other_count: 0, + hydrate_ms: 0, }, ...(parentTraceId !== undefined ? { parentTraceId } : {}), }); @@ -1503,6 +1512,9 @@ export function createSpawnAgentTool(deps: AgentFleetDeps): AgentTool { // Fire and forget: this handler must return before the worker finishes. // Wait JSON projects stored WorkerLifecycle plus the per-install overlay. + // Setup (worktree snapshot, admission, lane checks) ends here; the + // worker run owns the remaining duration. + setupEndMs = Date.now(); deps .run(params) .then((result) => { diff --git a/src/subagent/index.test.ts b/src/subagent/index.test.ts index d374f65bf..303259ab5 100644 --- a/src/subagent/index.test.ts +++ b/src/subagent/index.test.ts @@ -36,6 +36,7 @@ import type { ReactorState, } from "@intx/types/runtime"; import { defined } from "../../tests/helpers/defined.js"; +import { classifyToolFamily } from "./run.js"; describe("sub-agent teardown", () => { test("disposeSubAgentSession closes agent, awaits stream, and disposes posix tools once", async () => { @@ -1424,3 +1425,30 @@ describe("buildDispatchBrief typed spawn contract", () => { expect(both).toContain("1. manage_tasks seed"); }); }); + +describe("classifyToolFamily", () => { + test("maps canonical engine names onto the six telemetry buckets", () => { + expect(classifyToolFamily("read_file")).toBe("read"); + expect(classifyToolFamily("write_file")).toBe("write"); + expect(classifyToolFamily("edit_file")).toBe("write"); + expect(classifyToolFamily("delete_file")).toBe("write"); + expect(classifyToolFamily("run_shell")).toBe("shell"); + expect(classifyToolFamily("shell_collect")).toBe("shell"); + expect(classifyToolFamily("grep")).toBe("search"); + expect(classifyToolFamily("search_files")).toBe("search"); + expect(classifyToolFamily("search_agents")).toBe("search"); + expect(classifyToolFamily("spawn_agent")).toBe("agent"); + expect(classifyToolFamily("wait_agents")).toBe("agent"); + expect(classifyToolFamily("ask_director")).toBe("agent"); + expect(classifyToolFamily("submit_result")).toBe("agent"); + }); + + test("falls back to other for orchestration, web, and unknown tools", () => { + expect(classifyToolFamily("manage_tasks")).toBe("other"); + expect(classifyToolFamily("web_fetch")).toBe("other"); + expect(classifyToolFamily("web_search")).toBe("other"); + expect(classifyToolFamily("mcp__plugin_tool")).toBe("other"); + expect(classifyToolFamily("something_unknown")).toBe("other"); + expect(classifyToolFamily("")).toBe("other"); + }); +}); diff --git a/src/subagent/run-settlement.test.ts b/src/subagent/run-settlement.test.ts index f457f5200..b030b0389 100644 --- a/src/subagent/run-settlement.test.ts +++ b/src/subagent/run-settlement.test.ts @@ -4,8 +4,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ReactorEmittedEvent } from "@intx/inference"; +import type { + ContextTransform, + ConversationTurn, + StrategyContext, +} from "@intx/types/runtime"; import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; +import { defined } from "../../tests/helpers/defined.js"; import { createPermissionGate } from "../permission/gate.js"; import type { SubAgentRunSettlement } from "./types.js"; @@ -193,3 +199,303 @@ test("pre-progress cancellation settles as cancelled without changing rejection" }); expect(Object.isFrozen(settlement)).toBe(true); }); + +test("tool calls accumulate into family buckets whose sum equals tool_call_count", async () => { + const cwd = await mkdtemp(join(tmpdir(), "corbits-run-buckets-")); + const originalError = new Error("bucket probe done"); + let settlement: Readonly | undefined; + const toolNames = [ + "read_file", + "read_file", + "write_file", + "run_shell", + "grep", + "spawn_agent", + "manage_tasks", + "mystery_tool", + ]; + + const caught = await withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async () => ({ + send: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + throw originalError; + }, + stream: () => + (async function* (): AsyncGenerator { + for (const [index, name] of toolNames.entries()) { + yield { + type: "tool.start", + seq: index + 1, + data: { + call: { id: `call-${index}`, name, arguments: {} }, + }, + } as ReactorEmittedEvent; + } + })(), + deliver: () => undefined, + close: async () => undefined, + setSource: () => undefined, + setSources: () => undefined, + history: async () => [], + checkpoints: async () => [], + readAt: async () => [], + blobReader: {}, + }), + }), + async () => { + const { runSubAgent } = await import("./run.js"); + try { + await runSubAgent({ + cwd, + workdirBase: join(cwd, ".ctx"), + permissionGate, + provider: { + providerName: "initial", + baseURL: "http://localhost", + model: "initial-model", + }, + description: "bucket probe", + prompt: "use tools across families", + onRunSettled: (summary) => { + settlement = summary; + }, + }); + } catch (error) { + return error; + } + throw new Error("expected runSubAgent to reject"); + }, + ); + + expect(caught).toBe(originalError); + expect(settlement).toMatchObject({ + tool_call_count: 8, + tool_read_count: 2, + tool_write_count: 1, + tool_shell_count: 1, + tool_search_count: 1, + tool_agent_count: 1, + tool_other_count: 2, + hydrate_ms: 0, + }); + const settled = defined(settlement); + expect(settled.tool_call_count).toBe( + settled.tool_read_count + + settled.tool_write_count + + settled.tool_shell_count + + settled.tool_search_count + + settled.tool_agent_count + + settled.tool_other_count, + ); +}); + +test("wire names bucket into families with zero other", async () => { + const cwd = await mkdtemp(join(tmpdir(), "corbits-run-wire-buckets-")); + const originalError = new Error("wire bucket probe done"); + let settlement: Readonly | undefined; + const toolNames = [ + "read", + "write", + "edit", + "delete", + "bash", + "glob", + "grep", + "shell", + "default.bash", + "bash__P1", + "apply_patch", + "close_agent", + "resume_agent", + "interrupt_agent", + "send_input", + "read_agent_trace", + ]; + + const caught = await withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async () => ({ + send: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + throw originalError; + }, + stream: () => + (async function* (): AsyncGenerator { + for (const [index, name] of toolNames.entries()) { + yield { + type: "tool.start", + seq: index + 1, + data: { + call: { id: `call-${index}`, name, arguments: {} }, + }, + } as ReactorEmittedEvent; + } + })(), + deliver: () => undefined, + close: async () => undefined, + setSource: () => undefined, + setSources: () => undefined, + history: async () => [], + checkpoints: async () => [], + readAt: async () => [], + blobReader: {}, + }), + }), + async () => { + const { runSubAgent } = await import("./run.js"); + try { + await runSubAgent({ + cwd, + workdirBase: join(cwd, ".ctx"), + permissionGate, + provider: { + providerName: "initial", + baseURL: "http://localhost", + model: "initial-model", + }, + description: "wire bucket probe", + prompt: "use tools across families", + onRunSettled: (summary) => { + settlement = summary; + }, + }); + } catch (error) { + return error; + } + throw new Error("expected runSubAgent to reject"); + }, + ); + + expect(caught).toBe(originalError); + expect(settlement).toMatchObject({ + tool_call_count: 16, + tool_read_count: 1, + tool_write_count: 4, + tool_shell_count: 4, + tool_search_count: 2, + tool_agent_count: 5, + tool_other_count: 0, + hydrate_ms: 0, + }); + const settled = defined(settlement); + expect(settled.tool_call_count).toBe( + settled.tool_read_count + + settled.tool_write_count + + settled.tool_shell_count + + settled.tool_search_count + + settled.tool_agent_count + + settled.tool_other_count, + ); +}); + +async function runWithRehydrateDelay( + delayMs: number, +): Promise | undefined> { + const cwd = await mkdtemp(join(tmpdir(), "corbits-run-hydrate-")); + const originalError = new Error("hydrate probe done"); + let settlement: Readonly | undefined; + let captured: ContextTransform[] | undefined; + const testCtx = {} as StrategyContext; + + await withMockedModuleDuring( + import.meta.resolve("../session/attachment-store.js"), + (real: typeof import("../session/attachment-store.js")) => ({ + ...real, + createAttachmentRehydrateTransform: () => ({ + name: "attachment-rehydrate", + version: "1", + apply: async (turns: ConversationTurn[]) => { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return { + output: turns, + record: { + strategy: "attachment-rehydrate", + version: "1", + parameters: {}, + reason: "noop", + decisions: {}, + }, + }; + }, + }), + }), + async () => + withMockedModuleDuring( + import.meta.resolve("../agent/live-tool-dispatch.js"), + (real: typeof import("../agent/live-tool-dispatch.js")) => ({ + ...real, + createAgentWithLiveToolDispatch: async (...args: unknown[]) => { + const env = args[1] as + | { deps?: { contextTransforms?: ContextTransform[] } } + | undefined; + captured = env?.deps?.contextTransforms; + return { + send: async () => { + const rehydrate = captured?.find( + (transform) => transform.name === "attachment-rehydrate", + ); + await rehydrate?.apply([], testCtx); + throw originalError; + }, + stream: () => + (async function* (): AsyncGenerator { + yield* []; + })(), + deliver: () => undefined, + close: async () => undefined, + setSource: () => undefined, + setSources: () => undefined, + history: async () => [], + checkpoints: async () => [], + readAt: async () => [], + blobReader: {}, + } as unknown as Awaited< + ReturnType + >; + }, + }), + async () => { + const { runSubAgent } = await import("./run.js"); + try { + await runSubAgent({ + cwd, + workdirBase: join(cwd, ".ctx"), + permissionGate, + provider: { + providerName: "initial", + baseURL: "http://localhost", + model: "initial-model", + }, + description: "hydrate probe", + prompt: "rehydrate attachments", + onRunSettled: (summary) => { + settlement = summary; + }, + }); + } catch { + return; + } + throw new Error("expected runSubAgent to reject"); + }, + ), + ); + return settlement; +} + +test("a slow attachment rehydrate accumulates into hydrate_ms", async () => { + const settlement = await runWithRehydrateDelay(60); + expect(defined(settlement).hydrate_ms).toBeGreaterThanOrEqual(50); +}); + +test("a noop attachment rehydrate reports hydrate_ms zero", async () => { + const settlement = await runWithRehydrateDelay(0); + expect(defined(settlement).hydrate_ms).toBe(0); +}); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index e35b395b5..5fa1dd647 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -497,6 +497,58 @@ export function shouldRequirePlanSubstance(input: { return input.intent === "plan" || input.directorId === "counsel"; } +export type ToolFamily = + | "read" + | "write" + | "shell" + | "search" + | "agent" + | "other"; + +// Bucket a canonical engine tool name for telemetry rollups. Unknown names +// fall into "other" so a new tool can never break the bucket-sum invariant. +export function classifyToolFamily(canonicalName: string): ToolFamily { + switch (canonicalName) { + case "read_file": + return "read"; + case "write_file": + case "edit_file": + case "delete_file": + case "apply_patch": + return "write"; + case "run_shell": + case "shell_collect": + return "shell"; + case "grep": + case "search_files": + case "search_agents": + return "search"; + case "spawn_agent": + case "wait_agents": + case "close_agent": + case "resume_agent": + case "interrupt_agent": + case "send_input": + case "read_agent_trace": + case "ask_director": + case "submit_result": + return "agent"; + default: + return "other"; + } +} + +// Resolve a raw tool.start name onto the engine id classifyToolFamily keys +// on. Prod traffic carries wire names (bash/glob/read/…) and decorated +// variants (`default.bash`, `bash__P1`); classifying those raw lands in +// "other". The `__…` instance-suffix strip is telemetry-local: grants and +// dispatch keep matching full names, and `mcp__server__tool` names still +// fall into "other" either way. +function bucketToolName(name: string): string { + const cut = name.indexOf("__"); + return canonicalToolName(cut > 0 ? name.slice(0, cut) : name); +} + const submitResultDefinition: ToolDefinition = { name: "submit_result", description: @@ -556,6 +608,13 @@ export async function runSubAgent( reasoning_tokens: 0, tool_call_count: 0, tool_error_count: 0, + tool_read_count: 0, + tool_write_count: 0, + tool_shell_count: 0, + tool_search_count: 0, + tool_agent_count: 0, + tool_other_count: 0, + hydrate_ms: 0, }; let terminalReason: SubAgentTerminalReason = "error"; let errorCount = 0; @@ -1202,6 +1261,27 @@ async function runSubAgentInner( bundle.sources[0]; if (workerSource === undefined) throw new Error("sub-agent source bundle is empty"); + // Time the attachment-rehydrate transform into hydrate_ms. The decorator + // wraps apply at this call site so resume followups ride the same closure + // with no signature change. + const rehydrate = createAttachmentRehydrateTransform((key) => + storage.readBlob(key), + ); + const rehydrateApply = rehydrate.apply.bind(rehydrate); + const timedRehydrate = { + ...rehydrate, + apply: async ( + turns: Parameters[0], + ctx: Parameters[1], + ) => { + const rehydrateStart = Date.now(); + try { + return await rehydrateApply(turns, ctx); + } finally { + telemetryRollup.hydrate_ms += Date.now() - rehydrateStart; + } + }, + }; agent = await createAgentWithLiveToolDispatch(def, { sources: bundle.sources, defaultSource: bundle.defaultSource, @@ -1216,9 +1296,7 @@ async function runSubAgentInner( // transforms up from there. deps: { ...inferenceDeps, - contextTransforms: [ - createAttachmentRehydrateTransform((key) => storage.readBlob(key)), - ], + contextTransforms: [timedRehydrate], }, audit, sessionId, @@ -1277,6 +1355,26 @@ async function runSubAgentInner( if (name !== null) { toolNamesUsed.push(name); telemetryRollup.tool_call_count += 1; + switch (classifyToolFamily(bucketToolName(name))) { + case "read": + telemetryRollup.tool_read_count += 1; + break; + case "write": + telemetryRollup.tool_write_count += 1; + break; + case "shell": + telemetryRollup.tool_shell_count += 1; + break; + case "search": + telemetryRollup.tool_search_count += 1; + break; + case "agent": + telemetryRollup.tool_agent_count += 1; + break; + case "other": + telemetryRollup.tool_other_count += 1; + break; + } params.onProgress?.({ description: params.description, toolName: name, diff --git a/src/subagent/spawn-agent-worktree.test.ts b/src/subagent/spawn-agent-worktree.test.ts index efcf37dae..9ccd58891 100644 --- a/src/subagent/spawn-agent-worktree.test.ts +++ b/src/subagent/spawn-agent-worktree.test.ts @@ -197,6 +197,77 @@ describe("spawn_agent worktree isolation", () => { tool_error_count: 0, }); expect(typeof ends[0]?.properties.duration_ms).toBe("number"); + expect(typeof ends[0]?.properties.setup_ms).toBe("number"); + }); + + test("emits setup_ms bounded by duration_ms on the success path", async () => { + const cwd = await mkdtemp(join(tmpdir(), "corbits-setup-ms-")); + tempDirs.push(cwd); + const { telemetry, events } = telemetryCapture(); + const sessions = createSubAgentSessionStore(); + const tool = createSpawnAgentTool({ + permissionGate: testPermissionGate, + cwd, + getWorkdirBase: () => cwd, + provider, + sessions, + fleetRecords: createFleetMailbox(sessions), + admission: unlimitedAdmissionQueue(), + run: async (params) => { + params.onAgentReady?.({ + close: async () => undefined, + interrupt: () => undefined, + followup: async () => "", + deliver: () => undefined, + }); + params.onRunSettled?.({ + turn_count: 1, + input_tokens: 1, + output_tokens: 1, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + tool_call_count: 0, + tool_error_count: 0, + tool_read_count: 0, + tool_write_count: 0, + tool_shell_count: 0, + tool_search_count: 0, + tool_agent_count: 0, + tool_other_count: 0, + hydrate_ms: 0, + error_count: 0, + duration_ms: 1, + model: "test-model", + terminal_reason: "complete" as const, + }); + return { + report: + "## Summary\nDone.\n## Findings\nok\n## Blockers\nnone\n## Paths\nnone", + }; + }, + telemetry, + }); + if (tool.kind !== "full") throw new Error("expected full tool"); + await tool.handler( + { + id: "setup-ms-ok", + name: "spawn_agent", + arguments: { + description: "setup probe", + prompt: "Do the work", + intent: "explore", + }, + }, + new AbortController().signal, + ); + await waitFor(() => events.some((event) => event.event === "subagent_end")); + const end = events.find((event) => event.event === "subagent_end"); + expect(typeof end?.properties.setup_ms).toBe("number"); + expect(typeof end?.properties.duration_ms).toBe("number"); + expect(end?.properties.setup_ms as number).toBeLessThanOrEqual( + end?.properties.duration_ms as number, + ); }); test("pairs pre-progress cancellation with a cancelled terminal event", async () => { @@ -220,6 +291,13 @@ describe("spawn_agent worktree isolation", () => { reasoning_tokens: 0, tool_call_count: 0, tool_error_count: 0, + tool_read_count: 0, + tool_write_count: 0, + tool_shell_count: 0, + tool_search_count: 0, + tool_agent_count: 0, + tool_other_count: 0, + hydrate_ms: 0, error_count: 1, duration_ms: 1, model: "test-model", @@ -355,6 +433,13 @@ describe("spawn_agent worktree isolation", () => { reasoning_tokens: 0, tool_call_count: 0, tool_error_count: 0, + tool_read_count: 0, + tool_write_count: 0, + tool_shell_count: 0, + tool_search_count: 0, + tool_agent_count: 0, + tool_other_count: 0, + hydrate_ms: 0, error_count: 0, duration_ms: 1, model: "test-model", diff --git a/src/subagent/types.ts b/src/subagent/types.ts index bb17fd35a..ba95c9e62 100644 --- a/src/subagent/types.ts +++ b/src/subagent/types.ts @@ -259,6 +259,13 @@ export interface SubAgentTelemetryRollup { reasoning_tokens: number; tool_call_count: number; tool_error_count: number; + tool_read_count: number; + tool_write_count: number; + tool_shell_count: number; + tool_search_count: number; + tool_agent_count: number; + tool_other_count: number; + hydrate_ms: number; } export type SubAgentTerminalReason = ForcedStopReason | "complete" | "error"; diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 8e6688a1d..645f3a0e5 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -176,6 +176,7 @@ const EVENT_PROPERTY_ALLOWLIST: Record = { "agent_name", "status", "duration_ms", + "setup_ms", "model", "turn_count", "input_tokens", @@ -185,6 +186,13 @@ const EVENT_PROPERTY_ALLOWLIST: Record = { "reasoning_tokens", "tool_call_count", "tool_error_count", + "tool_read_count", + "tool_write_count", + "tool_shell_count", + "tool_search_count", + "tool_agent_count", + "tool_other_count", + "hydrate_ms", "stop_reason", "parent_trace_id", ], diff --git a/src/telemetry/product-events.ts b/src/telemetry/product-events.ts index 1eff10728..90c4e783f 100644 --- a/src/telemetry/product-events.ts +++ b/src/telemetry/product-events.ts @@ -32,6 +32,8 @@ export interface CaptureSubagentEndArgs { agentName: string; status: string; durationMs: number; + /** Setup (worktree snapshot, admission, lane checks) elapsed; omit when unknown. */ + setupMs?: number; /** Canonical model id from the provider, never a free-text source label. */ model?: string; stopReason?: SubAgentTerminalReason | "setup_error"; @@ -52,6 +54,9 @@ export function buildSubagentEndProperties( status: args.status, duration_ms: args.durationMs, }; + if (args.setupMs !== undefined) { + props.setup_ms = args.setupMs; + } if (args.model !== undefined && args.model.length > 0) { props.model = args.model; } @@ -70,6 +75,13 @@ export function buildSubagentEndProperties( props.reasoning_tokens = args.rollup.reasoning_tokens; props.tool_call_count = args.rollup.tool_call_count; props.tool_error_count = args.rollup.tool_error_count; + props.tool_read_count = args.rollup.tool_read_count; + props.tool_write_count = args.rollup.tool_write_count; + props.tool_shell_count = args.rollup.tool_shell_count; + props.tool_search_count = args.rollup.tool_search_count; + props.tool_agent_count = args.rollup.tool_agent_count; + props.tool_other_count = args.rollup.tool_other_count; + props.hydrate_ms = args.rollup.hydrate_ms; } return props; } diff --git a/tests/unit/telemetry-product-events.test.ts b/tests/unit/telemetry-product-events.test.ts index 6bd18ed5d..3f20f6e41 100644 --- a/tests/unit/telemetry-product-events.test.ts +++ b/tests/unit/telemetry-product-events.test.ts @@ -397,6 +397,13 @@ test('subagent events bucket a project-defined profile id to "custom"', async () reasoning_tokens: 0, tool_call_count: 3, tool_error_count: 1, + tool_read_count: 1, + tool_write_count: 1, + tool_shell_count: 1, + tool_search_count: 0, + tool_agent_count: 0, + tool_other_count: 0, + hydrate_ms: 0, error_count: 0, duration_ms: 10, model: "test-model", @@ -448,6 +455,14 @@ test('subagent events bucket a project-defined profile id to "custom"', async () expect(end?.properties.input_tokens).toBe(10); expect(end?.properties.output_tokens).toBe(5); expect(end?.properties.stop_reason).toBe("deadline"); + expect(typeof end?.properties.setup_ms).toBe("number"); + expect(end?.properties.tool_read_count).toBe(1); + expect(end?.properties.tool_write_count).toBe(1); + expect(end?.properties.tool_shell_count).toBe(1); + expect(end?.properties.tool_search_count).toBe(0); + expect(end?.properties.tool_agent_count).toBe(0); + expect(end?.properties.tool_other_count).toBe(0); + expect(end?.properties.hydrate_ms).toBe(0); // No in-flight parent turn was noted — omit rather than invent. expect(end?.properties.parent_trace_id).toBeUndefined(); expect(await wire()).not.toContain("acmecorp"); @@ -528,6 +543,7 @@ test("buildSubagentEndProperties shapes rollup fields and omits empty parentTrac agentName: "builder", status: "completed", durationMs: 42, + setupMs: 7, model: "gpt-test", stopReason: "deadline", parentTraceId: "sess:turn:3", @@ -540,12 +556,20 @@ test("buildSubagentEndProperties shapes rollup fields and omits empty parentTrac reasoning_tokens: 3, tool_call_count: 7, tool_error_count: 1, + tool_read_count: 2, + tool_write_count: 1, + tool_shell_count: 1, + tool_search_count: 1, + tool_agent_count: 1, + tool_other_count: 1, + hydrate_ms: 5, }, }); expect(withRollup).toEqual({ agent_name: "builder", status: "completed", duration_ms: 42, + setup_ms: 7, model: "gpt-test", stop_reason: "deadline", parent_trace_id: "sess:turn:3", @@ -557,6 +581,13 @@ test("buildSubagentEndProperties shapes rollup fields and omits empty parentTrac reasoning_tokens: 3, tool_call_count: 7, tool_error_count: 1, + tool_read_count: 2, + tool_write_count: 1, + tool_shell_count: 1, + tool_search_count: 1, + tool_agent_count: 1, + tool_other_count: 1, + hydrate_ms: 5, }); const bare = buildSubagentEndProperties({ @@ -565,6 +596,14 @@ test("buildSubagentEndProperties shapes rollup fields and omits empty parentTrac durationMs: 1, parentTraceId: "", }); + expect(bare.setup_ms).toBeUndefined(); + expect(bare.tool_read_count).toBeUndefined(); + expect(bare.tool_write_count).toBeUndefined(); + expect(bare.tool_shell_count).toBeUndefined(); + expect(bare.tool_search_count).toBeUndefined(); + expect(bare.tool_agent_count).toBeUndefined(); + expect(bare.tool_other_count).toBeUndefined(); + expect(bare.hydrate_ms).toBeUndefined(); expect(bare.parent_trace_id).toBeUndefined(); expect(bare.turn_count).toBeUndefined(); // Must not invent a parent from last-completed feedback state.