From 646dffa59c1f2388011b1ecdee56f1080e57f6c9 Mon Sep 17 00:00:00 2001 From: "Mikhail f. Shiryaev" Date: Sat, 25 Jul 2026 16:53:42 +0200 Subject: [PATCH 1/2] Fix gate tests for `npm --json` output format change from array to object --- tests/check-pack.ts | 9 +++------ tests/check-package-load.ts | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tests/check-pack.ts b/tests/check-pack.ts index e20d1d0..7808106 100644 --- a/tests/check-pack.ts +++ b/tests/check-pack.ts @@ -10,12 +10,9 @@ const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { cwd: packageR if (result.stderr.length > 0) process.stderr.write(result.stderr); assert.equal(result.status, 0, result.error?.message ?? result.stderr); -const firstJson = result.stdout.indexOf("["); -const lastJson = result.stdout.lastIndexOf("]"); -assert.equal(firstJson >= 0 && lastJson >= firstJson, true, "npm pack did not emit JSON"); -const parsed: unknown = JSON.parse(result.stdout.slice(firstJson, lastJson + 1)); -assert.equal(Array.isArray(parsed), true, "npm pack JSON should be an array"); -const manifest = parsed[0]; +const parsed: unknown = JSON.parse(result.stdout.trim()); +assert.equal(typeof parsed === "object" && parsed !== null, true, "npm pack did not emit JSON"); +const manifest = Array.isArray(parsed) ? parsed[0] : Object.values(parsed as Record)[0]; assert.equal(typeof manifest === "object" && manifest !== null && "files" in manifest, true, "npm pack JSON should include files"); const rawFiles = manifest.files; assert.equal(Array.isArray(rawFiles), true, "npm pack files should be an array"); diff --git a/tests/check-package-load.ts b/tests/check-package-load.ts index 2139241..6b12552 100644 --- a/tests/check-package-load.ts +++ b/tests/check-package-load.ts @@ -121,12 +121,9 @@ async function assertSkillManifest(root: string, label: string, skills: unknown) } function parsePackTarballPath(stdout: string, destination: string): string { - const firstJson = stdout.indexOf("["); - const lastJson = stdout.lastIndexOf("]"); - assert.equal(firstJson >= 0 && lastJson >= firstJson, true, "npm pack did not emit JSON"); - const parsed: unknown = JSON.parse(stdout.slice(firstJson, lastJson + 1)); - assert.equal(Array.isArray(parsed), true, "npm pack JSON should be an array"); - const manifest: unknown = parsed[0]; + const parsed: unknown = JSON.parse(stdout.trim()); + assert.equal(typeof parsed === "object" && parsed !== null, true, "npm pack did not emit JSON"); + const manifest: unknown = Array.isArray(parsed) ? parsed[0] : Object.values(parsed as Record)[0]; if (!isRecord(manifest)) throw new Error("npm pack entry should be an object"); assert.equal(typeof manifest.filename, "string", "npm pack entry should include filename"); return join(destination, manifest.filename); From e521add6962f32afd6dbfe65c6f3b1251f8616f5 Mon Sep 17 00:00:00 2001 From: "Mikhail f. Shiryaev" Date: Fri, 24 Jul 2026 20:18:11 +0200 Subject: [PATCH 2/2] Wire the teams cost into the status footer --- CHANGELOG.md | 2 + README.md | 16 +++++++ extensions/multiagent/index.ts | 46 +++++++++++++++++-- extensions/multiagent/src/detached-run.ts | 5 +- extensions/multiagent/src/detached-state.ts | 5 +- extensions/multiagent/src/result-format.ts | 5 +- .../multiagent/src/result-model-text.ts | 18 ++++++++ .../multiagent/src/rpc-child-controller.ts | 10 ++-- extensions/multiagent/src/rpc-child-types.ts | 3 +- .../multiagent/src/rpc-message-end-handler.ts | 24 +++++++++- extensions/multiagent/src/rpc-record-utils.ts | 20 +++++++- .../multiagent/src/rpc-step-result-builder.ts | 10 ++-- extensions/multiagent/src/run-snapshot.ts | 21 +++++++-- extensions/multiagent/src/types.ts | 10 ++++ skills/pi-multiagent/SKILL.md | 2 +- tests/check-public-docs.ts | 2 +- tests/rendering.test.ts | 24 +++++----- 17 files changed, 185 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2202f33..27fcc5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added child token usage and cost tracking: each step accumulates usage from `message_end` RPC records and surfaces it as `StepSnapshot.usage` (per-step) and `RunSnapshot.teamUsage` (run total) in `run_status`/`step_result` model output. In interactive Pi, a separate footer status line (`team: $X.XXX ↑Xk ↓Xk ...`) accumulates cost across all `agent_team` runs in the session; disable it with the `agent_team:footer-cost` flag. + ## 0.9.8 - 2026-07-04 - Made child Pi sessions mandatory and observable: child launches now use normal persistent Pi sessions with deterministic names/session-dir propagation, `run_status`/`step_result`/artifacts report child session metadata, progress watchdog diagnostics preserve stalled-child evidence, failed steps retain safe partial evidence, read-only idempotent transport failures retry once only when no child/output/message evidence exists, and parent-message queue observations distinguish accepted, queued, consumed, and no-next-turn states. diff --git a/README.md b/README.md index a4c2fd5..06e78ad 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,22 @@ Inspect the file's authority, tools, extension grants, prompts, tasks, and `cwd` Do not point `graphFile` at installed package/example paths. Packaged examples are references to copy and adapt. Do not put `action`, `runId`, nested `graphFile`, or other control fields inside the graph file. +## Cost visibility + +After each terminal `agent_team` run, a separate footer status line shows the accumulated child token cost for the session: + +``` +team: $1.234 ↑80k ↓16k R4.0M W91k +``` + +This is distinct from the parent session's own `$X.XXX` counter. Disable it: + +``` +/flag agent_team:footer-cost false +``` + +Per-step cost and the run total also appear in `run_status` and `step_result` output. + ## Safe operating rules - Prefer one direct pass when delegation would add noise. diff --git a/extensions/multiagent/index.ts b/extensions/multiagent/index.ts index 55581dd..2468d1b 100644 --- a/extensions/multiagent/index.ts +++ b/extensions/multiagent/index.ts @@ -13,12 +13,13 @@ import { AgentTeamLiveRunsWidget, formatAgentTeamNoticeText, renderAgentTeamCall import { describeOutputLimit } from "./src/result-format.ts"; import { AgentTeamSchema, type AgentTeamInput } from "./src/schemas.ts"; import { readSubagentSkillConfig, SUBAGENT_SKILLS_FLAG } from "./src/subagent-skills-config.ts"; -import type { AgentDiagnostic, AgentInvocationDefaults, AgentTeamDetails, LibraryOptions, ParentToolInfo, ParentToolInventory } from "./src/types.ts"; +import type { AgentDiagnostic, AgentInvocationDefaults, AgentTeamDetails, LibraryOptions, ParentToolInfo, ParentToolInventory, StepUsage } from "./src/types.ts"; import { getParentSkillInventory } from "./src/caller-skills.ts"; const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const packageAgentsDir = join(packageRoot, "agents"); const NOTICE_MESSAGE_TYPE = "agent_team.notice"; +const FOOTER_COST_FLAG = "agent_team:footer-cost"; const validateAgentTeamInput = Compile(AgentTeamSchema); /** Host-controlled extension seams for deterministic package-load and fake-Pi lifecycle probes. */ @@ -35,16 +36,23 @@ export default function multiagentExtension(pi: ExtensionAPI) { export function registerMultiagentExtension(pi: ExtensionAPI, extensionOptions: MultiagentExtensionOptions = {}) { const liveRunUiBySession = new Map(); const closedUiSessions = new Set(); + const sessionTeamUsage = new Map(); + const countedRunsBySession = new Map>(); pi.on("session_shutdown", (event: { reason?: string }, ctx) => { const reason = event.reason ? `Parent Pi session shutdown: ${event.reason}.` : "Parent Pi session shutdown."; const sessionId = ctx.sessionManager.getSessionId(); closedUiSessions.add(sessionId); for (const run of listDetachedRuns()) if (run.isOwnedBy(sessionId) && !run.snapshot().terminal) run.cancel(reason, { forceKill: true }); clearRunWidget(ctx, sessionId, liveRunUiBySession); + const hadCost = sessionTeamUsage.has(sessionId); + sessionTeamUsage.delete(sessionId); + countedRunsBySession.delete(sessionId); + if (ctx.hasUI && hadCost) ctx.ui.setStatus(FOOTER_COST_FLAG, undefined); }); pi.registerMessageRenderer(NOTICE_MESSAGE_TYPE, (message, options, theme) => renderAgentTeamNoticeMessage(message.details, message.content, options, theme)); pi.on("tool_result", (event) => agentTeamToolResultErrorOverride(event)); pi.registerFlag(SUBAGENT_SKILLS_FLAG, { description: "Subagent Pi skill propagation: enabled or disabled. Default enabled gives each child all caller-visible skills.", type: "string", default: "enabled" }); + pi.registerFlag(FOOTER_COST_FLAG, { description: "Show accumulated child team cost as a separate footer status line. Set to false to disable.", type: "boolean", default: true }); pi.registerTool({ name: "agent_team", label: "Agent Team", @@ -75,7 +83,7 @@ export function registerMultiagentExtension(pi: ExtensionAPI, extensionOptions: const sessionDir = childSessionDirFromParent(ctx.sessionManager); closedUiSessions.delete(sessionId); reconcileRunWidget(ctx, sessionId, liveRunUiBySession); - const runUi = createRunUiHandlers(pi, ctx, sessionId, liveRunUiBySession, closedUiSessions); + const runUi = createRunUiHandlers(pi, ctx, sessionId, liveRunUiBySession, closedUiSessions, sessionTeamUsage, countedRunsBySession); const subagentSkills = readSubagentSkillConfig(pi.getFlag(SUBAGENT_SKILLS_FLAG)); const preflight = validatePreflightShape(params); const schemaValid = validateAgentTeamInput.Check(params); @@ -111,9 +119,10 @@ export function agentTeamToolResultErrorOverride(event: ToolResultEvent): { isEr return details.ok === false ? { isError: true } : undefined; } -function createRunUiHandlers(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, liveRunUiBySession: Map, closedUiSessions: Set): { update: (details: AgentTeamDetails) => string | undefined; notice: (details: AgentTeamDetails) => string | undefined } { +function createRunUiHandlers(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, liveRunUiBySession: Map, closedUiSessions: Set, sessionTeamUsage: Map, countedRunsBySession: Map>): { update: (details: AgentTeamDetails) => string | undefined; notice: (details: AgentTeamDetails) => string | undefined } { return { update(details) { + accumulateTeamCost(pi, ctx, sessionId, details, sessionTeamUsage, countedRunsBySession); return updateRunWidget(ctx, sessionId, details, liveRunUiBySession, closedUiSessions); }, notice(details) { @@ -122,6 +131,37 @@ function createRunUiHandlers(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: }; } +function accumulateTeamCost(pi: ExtensionAPI, ctx: ExtensionContext, sessionId: string, details: AgentTeamDetails, sessionTeamUsage: Map, countedRunsBySession: Map>): void { + if (!ctx.hasUI || !details.run?.terminal || !details.run.teamUsage) return; + if (pi.getFlag(FOOTER_COST_FLAG) === false) return; + const counted = countedRunsBySession.get(sessionId) ?? new Set(); + if (counted.has(details.run.runId)) return; + counted.add(details.run.runId); + countedRunsBySession.set(sessionId, counted); + const prev = sessionTeamUsage.get(sessionId) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + const u = details.run.teamUsage; + const next: StepUsage = { input: prev.input + u.input, output: prev.output + u.output, cacheRead: prev.cacheRead + u.cacheRead, cacheWrite: prev.cacheWrite + u.cacheWrite, cost: prev.cost + u.cost }; + sessionTeamUsage.set(sessionId, next); + ctx.ui.setStatus(FOOTER_COST_FLAG, formatTeamCostLine(next)); +} + +function formatTeamCostLine(u: StepUsage): string { + const parts: string[] = []; + if (u.input > 0) parts.push(`\u2191${fmtFooterTokens(u.input)}`); + if (u.output > 0) parts.push(`\u2193${fmtFooterTokens(u.output)}`); + if (u.cacheRead > 0) parts.push(`R${fmtFooterTokens(u.cacheRead)}`); + if (u.cacheWrite > 0) parts.push(`W${fmtFooterTokens(u.cacheWrite)}`); + parts.push(`$${u.cost.toFixed(3)}`); + return `team: ${parts.join(" ")}`; +} + +function fmtFooterTokens(n: number): string { + if (n < 1000) return String(n); + if (n < 10_000) return `${(n / 1000).toFixed(1)}k`; + if (n < 1_000_000) return `${Math.round(n / 1000)}k`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + interface LiveRunUiState { cards: Map; component: AgentTeamLiveRunsWidget | undefined; diff --git a/extensions/multiagent/src/detached-run.ts b/extensions/multiagent/src/detached-run.ts index 5ee0a97..7e5cfd1 100644 --- a/extensions/multiagent/src/detached-run.ts +++ b/extensions/multiagent/src/detached-run.ts @@ -16,7 +16,7 @@ import { terminalRunStatus } from "./run-terminal-status.ts"; import { RunWaiters } from "./run-waiters.ts"; import { createRunUiCallback } from "./run-ui-callback.ts"; import { makeDetails, type AgentTeamRuntimeOptions, unrefTimer } from "./runtime-options.ts"; -import { buildRunSnapshot, buildStepSnapshots, countStepStatuses, findSinkStepIds } from "./run-snapshot.ts"; +import { buildRunSnapshot, buildStepSnapshots, countStepStatuses, findSinkStepIds, sumStepUsages } from "./run-snapshot.ts"; import { createStepOutputArtifact } from "./step-output-artifact.ts"; import { stalledStepBlockerMessage } from "./stalled-step-diagnostics.ts"; import { effectiveAgentInvocation } from "./agent-invocation.ts"; @@ -132,7 +132,7 @@ export class DetachedRun { snapshot() { const liveStepIds = this.stepSnapshots().filter((step) => step.status === "running").map((step) => step.id); - return buildRunSnapshot({ runId: this.id, objective: this.graph.objective, status: this.status, createdAt: this.createdAt, updatedAt: this.updatedAt, retentionSeconds: this.graph.options.terminalRetentionSeconds, liveStepIds, sinkStepIds: this.sinkStepIds(), lastEvent: this.lastEventSummary(), canMessage: this.status === "running" && liveStepIds.length > 0, canCancel: this.status === "running" || this.status === "canceling", counts: this.counts() }); + return buildRunSnapshot({ runId: this.id, objective: this.graph.objective, status: this.status, createdAt: this.createdAt, updatedAt: this.updatedAt, retentionSeconds: this.graph.options.terminalRetentionSeconds, liveStepIds, sinkStepIds: this.sinkStepIds(), lastEvent: this.lastEventSummary(), canMessage: this.status === "running" && liveStepIds.length > 0, canCancel: this.status === "running" || this.status === "canceling", counts: this.counts(), teamUsage: sumStepUsages(this.states.values()) }); } private async schedule() { @@ -200,6 +200,7 @@ export class DetachedRun { state.nonFinalText = result.nonFinalText; state.assistantFinals = result.assistantFinals; state.childSession = result.childSession; + state.usage = result.usage; state.output = this.createStepOutput(state, status, result.text, result.assistantFinals, result.errorMessage, result.nonFinalText); if (result.stderr.length > 0) this.appendEvent({ stepId: state.spec.id, type: "diagnostic", label: "stderr", preview: result.stderr, status: "done" }); this.finishState(state, status, result.errorMessage); diff --git a/extensions/multiagent/src/detached-state.ts b/extensions/multiagent/src/detached-state.ts index 6085d8e..2dc2b90 100644 --- a/extensions/multiagent/src/detached-state.ts +++ b/extensions/multiagent/src/detached-state.ts @@ -1,7 +1,7 @@ /** Mutable per-step detached-run state. */ import type { RpcChildController } from "./rpc-child-controller.ts"; -import type { ChildSessionMetadata, StepOutput, StepRetryRecord, StepStatus, TeamStepSpec } from "./types.ts"; +import type { ChildSessionMetadata, StepOutput, StepRetryRecord, StepStatus, StepUsage, TeamStepSpec } from "./types.ts"; export interface StepState { spec: TeamStepSpec; @@ -19,8 +19,9 @@ export interface StepState { liveTextEventChars: number; controller: RpcChildController | undefined; promise: Promise | undefined; + usage: StepUsage | undefined; } export function createPendingStepState(spec: TeamStepSpec): StepState { - return { spec, status: "pending", startedAt: undefined, endedAt: undefined, errorMessage: undefined, output: undefined, finalText: undefined, nonFinalText: undefined, assistantFinals: [], childSession: undefined, retryHistory: [], liveText: "", liveTextEventChars: 0, controller: undefined, promise: undefined }; + return { spec, status: "pending", startedAt: undefined, endedAt: undefined, errorMessage: undefined, output: undefined, finalText: undefined, nonFinalText: undefined, assistantFinals: [], childSession: undefined, retryHistory: [], liveText: "", liveTextEventChars: 0, controller: undefined, promise: undefined, usage: undefined }; } diff --git a/extensions/multiagent/src/result-format.ts b/extensions/multiagent/src/result-format.ts index 579c301..4d9e73c 100644 --- a/extensions/multiagent/src/result-format.ts +++ b/extensions/multiagent/src/result-format.ts @@ -9,6 +9,7 @@ import { compactOutputChildSession, formatOutputChildSession, optionalChildSessi import type { AgentTeamDetails, BackgroundEvent, RunSnapshot, StepOutput, StepSnapshot } from "./types.ts"; export { boundedModelText, escapeOutputBlockMarkers, modelText } from "./result-model-text.ts"; +import { formatRunTeamCost, formatStepCost } from "./result-model-text.ts"; export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, describeOutputLimit, truncateHead } from "./result-truncation.ts"; const OBJECTIVE_PREVIEW_CHARS = 1000; @@ -127,7 +128,7 @@ function formatRunAction(title: string, details: AgentTeamDetails): string { function formatRunSnapshot(run: RunSnapshot): string { const controls = `Exceptional controls: message=${run.canMessage ? "live-clarification-only" : "unavailable"} cancel=${run.canCancel ? "stop-only" : "unavailable"} cleanup=${run.canCleanup ? "terminal-only" : "unavailable"}. Availability is not a recommendation; wait for notices when work is healthy.`; - return [`Run: ${modelText(run.runId)}`, `Objective: ${boundedModelText(run.objective, OBJECTIVE_PREVIEW_CHARS)}`, `Status: ${modelText(run.status)} terminal=${run.terminal}`, `Updated: ${modelText(run.updatedAt)}`, `Sinks: ${run.sinkStepIds.length > 0 ? run.sinkStepIds.map(modelText).join(", ") : "none"}`, `Live steps: ${run.liveStepIds.length > 0 ? run.liveStepIds.map(modelText).join(", ") : "none"}`, `Counts: ${formatCounts(run.counts)}`, run.lastEvent ? `Last event: ${modelText(run.lastEvent)}` : "Last event: none", controls].join("\n"); + return [`Run: ${modelText(run.runId)}`, `Objective: ${boundedModelText(run.objective, OBJECTIVE_PREVIEW_CHARS)}`, `Status: ${modelText(run.status)} terminal=${run.terminal}`, `Updated: ${modelText(run.updatedAt)}`, `Sinks: ${run.sinkStepIds.length > 0 ? run.sinkStepIds.map(modelText).join(", ") : "none"}`, `Live steps: ${run.liveStepIds.length > 0 ? run.liveStepIds.map(modelText).join(", ") : "none"}`, `Counts: ${formatCounts(run.counts)}`, formatRunTeamCost(run.teamUsage), run.lastEvent ? `Last event: ${modelText(run.lastEvent)}` : "Last event: none", controls].filter(Boolean).join("\n"); } function formatEffectiveStepTools(steps: StepSnapshot[]): string { @@ -171,7 +172,7 @@ function formatStep(step: StepSnapshot): string { const activity = step.lastActivity ? ` lastActivity=${JSON.stringify(modelText(step.lastActivity))}` : ""; const needs = step.needs.length > 0 ? step.needs.map(modelText).join(",") : "none"; const after = step.after.length > 0 ? ` after=${step.after.map(modelText).join(",")}` : ""; - return `- ${modelText(step.id)}: ${modelText(step.status)} agent=${modelText(step.agentRef)}${optionalScalar(" model", step.model)}${optionalScalar(" thinking", step.thinking)}${optionalOutputLimit(step)} effectiveTools=${formatList(step.effectiveTools)}${optionalList(" extensionTools", step.extensionTools)}${optionalList(" skills", step.callerSkills)} needs=${needs}${after}${optionalChildSession(step)}${optionalRetryHistory(step)}${activity}${error}`; + return `- ${modelText(step.id)}: ${modelText(step.status)} agent=${modelText(step.agentRef)}${optionalScalar(" model", step.model)}${optionalScalar(" thinking", step.thinking)}${optionalOutputLimit(step)} effectiveTools=${formatList(step.effectiveTools)}${optionalList(" extensionTools", step.extensionTools)}${optionalList(" skills", step.callerSkills)} needs=${needs}${after}${formatStepCost(step.usage)}${optionalChildSession(step)}${optionalRetryHistory(step)}${activity}${error}`; } function formatEvent(event: BackgroundEvent): string { diff --git a/extensions/multiagent/src/result-model-text.ts b/extensions/multiagent/src/result-model-text.ts index 4873395..52f5329 100644 --- a/extensions/multiagent/src/result-model-text.ts +++ b/extensions/multiagent/src/result-model-text.ts @@ -1,3 +1,21 @@ +import type { RunSnapshot, StepUsage } from "./types.ts"; + +export function fmtTokens(n: number): string { + if (n < 1000) return String(n); + if (n < 10_000) return `${(n / 1000).toFixed(1)}k`; + if (n < 1_000_000) return `${Math.round(n / 1000)}k`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + +export function formatRunTeamCost(teamUsage: RunSnapshot["teamUsage"]): string | undefined { + if (!teamUsage) return undefined; + return `Team cost: $${teamUsage.cost.toFixed(4)} tokens: \u2191${fmtTokens(teamUsage.input)} \u2193${fmtTokens(teamUsage.output)} R${fmtTokens(teamUsage.cacheRead)} W${fmtTokens(teamUsage.cacheWrite)}`; +} + +export function formatStepCost(usage: StepUsage | undefined): string { + return usage ? ` cost=$${usage.cost.toFixed(4)}` : ""; +} + export function modelText(text: string): string { return escapeOutputBlockMarkers(text).replace(/\s+/g, " ").trim(); } diff --git a/extensions/multiagent/src/rpc-child-controller.ts b/extensions/multiagent/src/rpc-child-controller.ts index 94952c0..a42680c 100644 --- a/extensions/multiagent/src/rpc-child-controller.ts +++ b/extensions/multiagent/src/rpc-child-controller.ts @@ -4,7 +4,7 @@ import { RpcCommandQueue, type RpcCommandAck } from "./rpc-command-queue.ts"; import { combinedAssistantFinals, envelopeParentMessage, extractAgentEndErrorMessage, extractAgentEndStopReason, extractEventText, hasAgentEndErrorMetadata, isAgentEndContextOverflow } from "./rpc-record-utils.ts"; import { STDERR_PREVIEW_CHARS, type StepStatus } from "./types.ts"; import { buildPiArgs, getPiInvocation, killProcessTree, type SpawnProcess } from "./child-launch.ts"; -import { type RpcJsonRecord } from "./rpc-jsonl.ts"; +import type { RpcJsonRecord } from "./rpc-jsonl.ts"; import { RpcChildListeners } from "./rpc-child-listeners.ts"; import { handleUnattendedUiRequest } from "./rpc-ui-request.ts"; import { ParentMessageBudget } from "./rpc-parent-message-budget.ts"; @@ -12,7 +12,7 @@ import { handleAssistantMessageUpdate } from "./rpc-message-update.ts"; import { terminateRpcChild } from "./rpc-child-termination.ts"; import { scheduleRpcExitCloseout } from "./rpc-child-exit-closeout.ts"; import { RpcChildObservability } from "./rpc-child-observability.ts"; -import { processAssistantMessageEnd } from "./rpc-message-end-handler.ts"; +import { processAssistantMessageEnd, RpcUsageTracker } from "./rpc-message-end-handler.ts"; import { emptyAssistantFinalFailure, failureRpcStepResult, successRpcStepResult, terminalStopReason } from "./rpc-step-result-builder.ts"; import { handleRpcToolEvent } from "./rpc-tool-event-handler.ts"; import type { RpcChildControllerOptions, RpcStepResult } from "./rpc-child-types.ts"; @@ -47,6 +47,7 @@ export class RpcChildController { private exitCloseTimer: ReturnType | undefined; private readonly listeners = new RpcChildListeners(); private spawnProcess: SpawnProcess; + private readonly usageTracker = new RpcUsageTracker(); constructor(options: RpcChildControllerOptions) { this.options = options; @@ -203,6 +204,7 @@ export class RpcChildController { this.lastAssistantErrorMessage = result.errorMessage; if (result.contextOverflowMessage !== undefined) this.enterContextOverflowRecovery("assistant", result.contextOverflowMessage); else if (result.outputBudgetFailure) this.failOutputBudget(result.outputBudgetFailure); + this.usageTracker.accumulate(record); } private failOutputBudget(failure: OutputBudgetFailure): void { @@ -232,11 +234,11 @@ export class RpcChildController { this.fail("failed", failure.message, failure.label); return; } - this.finalize(successRpcStepResult({ text, assistantFinals: [...this.assistantFinals], stderr: this.stderr, childSession: this.observability.session, parentMessagesAccepted: this.observability.parentMessagesAccepted })); + this.finalize(successRpcStepResult({ text, assistantFinals: [...this.assistantFinals], stderr: this.stderr, childSession: this.observability.session, parentMessagesAccepted: this.observability.parentMessagesAccepted, usage: this.usageTracker.snapshot() })); } private failureResult(status: StepStatus, message: string): RpcStepResult { - return failureRpcStepResult({ status, message, output: this.output, liveText: this.liveText, assistantFinals: [...this.assistantFinals], stderr: this.stderr, lastAssistantStopReason: this.lastAssistantStopReason, childSession: this.observability.session, parentMessagesAccepted: this.observability.parentMessagesAccepted }); + return failureRpcStepResult({ status, message, output: this.output, liveText: this.liveText, assistantFinals: [...this.assistantFinals], stderr: this.stderr, lastAssistantStopReason: this.lastAssistantStopReason, childSession: this.observability.session, parentMessagesAccepted: this.observability.parentMessagesAccepted, usage: this.usageTracker.snapshot() }); } private enterContextOverflowRecovery(label: string, message: string): void { diff --git a/extensions/multiagent/src/rpc-child-types.ts b/extensions/multiagent/src/rpc-child-types.ts index b598589..0fe3236 100644 --- a/extensions/multiagent/src/rpc-child-types.ts +++ b/extensions/multiagent/src/rpc-child-types.ts @@ -1,6 +1,6 @@ /** Shared RPC child controller types. */ -import type { AgentInvocationDefaults, ChildSessionMetadata, ResolvedAgent, StepOutputLimit, StepStatus, TeamLimits } from "./types.ts"; +import type { AgentInvocationDefaults, ChildSessionMetadata, ResolvedAgent, StepOutputLimit, StepStatus, StepUsage, TeamLimits } from "./types.ts"; export type RpcChildEventInput = { type: "rpc" | "assistant_final" | "tool" | "diagnostic" | "parent_message" | "ui"; label?: string; preview?: string; status?: string }; @@ -30,4 +30,5 @@ export interface RpcStepResult { nonFinalText?: string; childSession?: ChildSessionMetadata; parentMessagesAccepted?: boolean; + usage?: StepUsage; } diff --git a/extensions/multiagent/src/rpc-message-end-handler.ts b/extensions/multiagent/src/rpc-message-end-handler.ts index 554cf2b..a475c59 100644 --- a/extensions/multiagent/src/rpc-message-end-handler.ts +++ b/extensions/multiagent/src/rpc-message-end-handler.ts @@ -1,6 +1,26 @@ -import { AssistantOutputBudget, type OutputBudgetFailure } from "./rpc-output-budget.ts"; -import { extractAssistantErrorMessage, extractAssistantStopReason, extractAssistantText, extractEventText, isContextOverflowStop } from "./rpc-record-utils.ts"; +import type { AssistantOutputBudget, OutputBudgetFailure } from "./rpc-output-budget.ts"; +import { extractAssistantErrorMessage, extractAssistantStopReason, extractAssistantText, extractEventText, extractMessageUsage, isContextOverflowStop } from "./rpc-record-utils.ts"; import type { RpcJsonRecord } from "./rpc-jsonl.ts"; +import type { StepUsage } from "./types.ts"; + +export class RpcUsageTracker { + private usage: StepUsage | undefined; + + accumulate(record: RpcJsonRecord): void { + const u = extractMessageUsage(record); + if (!u) return; + if (!this.usage) this.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + this.usage.input += u.input; + this.usage.output += u.output; + this.usage.cacheRead += u.cacheRead; + this.usage.cacheWrite += u.cacheWrite; + this.usage.cost += u.cost; + } + + snapshot(): StepUsage | undefined { + return this.usage ? { ...this.usage } : undefined; + } +} export interface MessageEndState { output: string; diff --git a/extensions/multiagent/src/rpc-record-utils.ts b/extensions/multiagent/src/rpc-record-utils.ts index a2c65d2..0885fd4 100644 --- a/extensions/multiagent/src/rpc-record-utils.ts +++ b/extensions/multiagent/src/rpc-record-utils.ts @@ -2,7 +2,7 @@ import { formatAssistantFinalMessages } from "./detached-output.ts"; import type { RpcJsonRecord } from "./rpc-jsonl.ts"; -import type { MessageChannel } from "./types.ts"; +import type { MessageChannel, StepUsage } from "./types.ts"; export function combinedAssistantFinals(texts: string[]): string { if (texts.length === 1) return texts[0] ?? ""; @@ -102,6 +102,24 @@ export function stringField(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +export function extractMessageUsage(record: RpcJsonRecord): StepUsage | undefined { + const message = assistantMessage(record); + if (!message) return undefined; + const u = isRecord(message.usage) ? message.usage : undefined; + if (!u) return undefined; + const input = numberField(u.input); + const output = numberField(u.output); + const cacheRead = numberField(u.cacheRead); + const cacheWrite = numberField(u.cacheWrite); + const costTotal = isRecord(u.cost) ? numberField(u.cost.total) : undefined; + if (input === undefined && output === undefined && costTotal === undefined) return undefined; + return { input: input ?? 0, output: output ?? 0, cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0, cost: costTotal ?? 0 }; +} + +function numberField(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + function assistantMessage(record: RpcJsonRecord): RpcJsonRecord | undefined { const message = isRecord(record.message) ? record.message : undefined; return message?.role === "assistant" ? message : undefined; diff --git a/extensions/multiagent/src/rpc-step-result-builder.ts b/extensions/multiagent/src/rpc-step-result-builder.ts index ac26fcb..29b9427 100644 --- a/extensions/multiagent/src/rpc-step-result-builder.ts +++ b/extensions/multiagent/src/rpc-step-result-builder.ts @@ -1,9 +1,9 @@ -import type { ChildSessionMetadata, StepStatus } from "./types.ts"; +import type { ChildSessionMetadata, StepStatus, StepUsage } from "./types.ts"; import type { RpcStepResult } from "./rpc-child-types.ts"; import { firstNonBlank } from "./rpc-tool-events.ts"; -export function successRpcStepResult(input: { text: string; assistantFinals: string[]; stderr: string; childSession: ChildSessionMetadata | undefined; parentMessagesAccepted: boolean }): RpcStepResult { - return { status: "succeeded", text: input.text, assistantFinals: input.assistantFinals, stderr: input.stderr, errorMessage: undefined, childSession: input.childSession, parentMessagesAccepted: input.parentMessagesAccepted }; +export function successRpcStepResult(input: { text: string; assistantFinals: string[]; stderr: string; childSession: ChildSessionMetadata | undefined; parentMessagesAccepted: boolean; usage?: StepUsage }): RpcStepResult { + return { status: "succeeded", text: input.text, assistantFinals: input.assistantFinals, stderr: input.stderr, errorMessage: undefined, childSession: input.childSession, parentMessagesAccepted: input.parentMessagesAccepted, usage: input.usage }; } export function terminalStopReason(agentEndStopReason: string | undefined, agentEndErrorMessage: string | undefined, lastAssistantStopReason: string | undefined, lastAssistantErrorMessage: string | undefined): { stopReason: string | undefined; errorMessage: string | undefined } { @@ -15,9 +15,9 @@ export function emptyAssistantFinalFailure(recoveringOverflow: boolean): { messa return recoveringOverflow ? { message: "context-overflow-unrecovered: child reported context overflow but did not produce a valid post-recovery assistant final.", label: "context-overflow-unrecovered" } : { message: "assistant-final-empty: no assistant final text captured after agent_end and command ACKs.", label: "assistant-final-empty" }; } -export function failureRpcStepResult(input: { status: StepStatus; message: string; output: string; liveText: string; assistantFinals: string[]; stderr: string; lastAssistantStopReason: string | undefined; childSession: ChildSessionMetadata | undefined; parentMessagesAccepted: boolean }): RpcStepResult { +export function failureRpcStepResult(input: { status: StepStatus; message: string; output: string; liveText: string; assistantFinals: string[]; stderr: string; lastAssistantStopReason: string | undefined; childSession: ChildSessionMetadata | undefined; parentMessagesAccepted: boolean; usage?: StepUsage }): RpcStepResult { const nonFinalText = nonFinalEvidence(input); - return { status: input.status, text: input.output, assistantFinals: input.assistantFinals, stderr: input.stderr, errorMessage: input.message, childSession: input.childSession, parentMessagesAccepted: input.parentMessagesAccepted, ...(nonFinalText ? { nonFinalText } : {}) }; + return { status: input.status, text: input.output, assistantFinals: input.assistantFinals, stderr: input.stderr, errorMessage: input.message, childSession: input.childSession, parentMessagesAccepted: input.parentMessagesAccepted, usage: input.usage, ...(nonFinalText ? { nonFinalText } : {}) }; } function nonFinalEvidence(input: { status: StepStatus; output: string; liveText: string; assistantFinals: string[]; lastAssistantStopReason: string | undefined }): string | undefined { diff --git a/extensions/multiagent/src/run-snapshot.ts b/extensions/multiagent/src/run-snapshot.ts index 98031d2..7da7a27 100644 --- a/extensions/multiagent/src/run-snapshot.ts +++ b/extensions/multiagent/src/run-snapshot.ts @@ -4,7 +4,7 @@ import { isTerminalRunStatus } from "./detached-output.ts"; import type { StepState } from "./detached-state.ts"; import type { StepActivityTracker } from "./step-activity.ts"; import { childToolNames } from "./tool-policy.ts"; -import type { AgentInvocationDefaults, RunSnapshot, RunStatus, StepArtifactReference, StepSnapshot, StepStatus, TeamStepSpec } from "./types.ts"; +import type { AgentInvocationDefaults, RunSnapshot, RunStatus, StepArtifactReference, StepSnapshot, StepStatus, StepUsage, TeamStepSpec } from "./types.ts"; import { effectiveAgentInvocation } from "./agent-invocation.ts"; import { nonDefaultStepOutputLimit } from "./step-output-limit.ts"; @@ -37,6 +37,7 @@ export function buildStepSnapshots(states: Iterable, activity: StepAc upstreamArtifacts: upstreamArtifactReferences(state.spec, byId), childSession: state.childSession, retryHistory: state.retryHistory.length > 0 ? state.retryHistory : undefined, + usage: state.usage ?? undefined, }; }); } @@ -59,9 +60,23 @@ function isTerminalStepStatus(status: StepStatus): boolean { return status !== "pending" && status !== "running"; } -export function buildRunSnapshot(input: { runId: string; objective: string; status: RunStatus; createdAt: string; updatedAt: string; retentionSeconds: number; liveStepIds: string[]; sinkStepIds: string[]; lastEvent: string | undefined; canMessage: boolean; canCancel: boolean; counts: Record }): RunSnapshot { +export function sumStepUsages(states: Iterable): StepUsage | undefined { + let total: StepUsage | undefined; + for (const state of states) { + if (!state.usage) continue; + if (!total) total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + total.input += state.usage.input; + total.output += state.usage.output; + total.cacheRead += state.usage.cacheRead; + total.cacheWrite += state.usage.cacheWrite; + total.cost += state.usage.cost; + } + return total; +} + +export function buildRunSnapshot(input: { runId: string; objective: string; status: RunStatus; createdAt: string; updatedAt: string; retentionSeconds: number; liveStepIds: string[]; sinkStepIds: string[]; lastEvent: string | undefined; canMessage: boolean; canCancel: boolean; counts: Record; teamUsage?: StepUsage }): RunSnapshot { const terminal = isTerminalRunStatus(input.status); - return { runId: input.runId, objective: input.objective, status: input.status, terminal, createdAt: input.createdAt, updatedAt: input.updatedAt, expiresAt: terminal ? new Date(Date.parse(input.updatedAt) + input.retentionSeconds * 1000).toISOString() : undefined, liveStepIds: input.liveStepIds, sinkStepIds: input.sinkStepIds, lastEvent: input.lastEvent, canMessage: input.canMessage, canCancel: input.canCancel, canCleanup: terminal, counts: input.counts }; + return { runId: input.runId, objective: input.objective, status: input.status, terminal, createdAt: input.createdAt, updatedAt: input.updatedAt, expiresAt: terminal ? new Date(Date.parse(input.updatedAt) + input.retentionSeconds * 1000).toISOString() : undefined, liveStepIds: input.liveStepIds, sinkStepIds: input.sinkStepIds, lastEvent: input.lastEvent, canMessage: input.canMessage, canCancel: input.canCancel, canCleanup: terminal, counts: input.counts, teamUsage: input.teamUsage }; } export function countStepStatuses(states: Iterable): Record { diff --git a/extensions/multiagent/src/types.ts b/extensions/multiagent/src/types.ts index 7995495..c31fa9b 100644 --- a/extensions/multiagent/src/types.ts +++ b/extensions/multiagent/src/types.ts @@ -8,6 +8,14 @@ export type InvocationAgentKind = "inline" | "library"; export type ThinkingLevel = "inherit" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type InvocationThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type RunStatus = "running" | "succeeded" | "mixed" | "failed" | "canceling" | "canceled" | "expired"; + +export interface StepUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; +} export type StepStatus = "pending" | "running" | "succeeded" | "failed" | "blocked" | "timed_out" | "canceled"; export type MessageChannel = "steer" | "follow_up"; export type NotifyMode = "none" | "final" | "milestones"; @@ -339,6 +347,7 @@ export interface RunSnapshot { canCancel: boolean; canCleanup: boolean; counts: Record; + teamUsage?: StepUsage; } export interface StepArtifactReference { @@ -387,6 +396,7 @@ export interface StepSnapshot { upstreamArtifacts?: StepArtifactReference[]; childSession?: ChildSessionMetadata; retryHistory?: StepRetryRecord[]; + usage?: StepUsage; } export interface StepOutput { diff --git a/skills/pi-multiagent/SKILL.md b/skills/pi-multiagent/SKILL.md index c975100..f7c5e6d 100644 --- a/skills/pi-multiagent/SKILL.md +++ b/skills/pi-multiagent/SKILL.md @@ -183,7 +183,7 @@ The file must be a regular relative `.json` file inside cwd, max 256 KiB. Symlin - Parent abort before registration cancels setup; parent abort after `runId` registration does not kill the detached run. In interactive Pi, Escape cancels or aborts the parent surface; after `start` returns a `runId`, use explicit `agent_team cancel` to stop detached work. - Live and retained run registries are process-local. On Pi session shutdown or reload, the extension requests cancellation of live registered runs owned by that session. `agent_team` is not crash-resumable, and in-memory `runId`s are not recoverable after reload. - Pushed notices are compact untrusted human receipts and omit the full child transcript. Terminal notices include terminal step artifact paths and retention expiry when available; milestone notices stay compact. -- Interactive Pi live progress belongs to the `agent_team:live` widget and pushed notices; `agent_team` does not write run/lane counts to Pi's shared footer status row. +- Interactive Pi live progress belongs to the `agent_team:live` widget and pushed notices. After each terminal run, `agent_team` updates a separate footer status line (`team: $X.XXX ↑Xk ↓Xk ...`) that accumulates child token cost across all runs in the session. Disable it with the `agent_team:footer-cost` flag (boolean, default `true`). This line is distinct from the parent session's own `$X.XXX` cost counter. - `run_status` gives compact state, sink artifacts, all terminal step artifact metadata, bounded task previews, cwd/upstream artifact references, diagnostics, effective tools/model lane, and optional structured wait receipt. In JSON/API/headless use, `run_status {runId, waitSeconds}` is the fallback when pushed notices are unavailable. - `step_result` is the one-step microscope. - Assistant text previews require `preview:true`; raw events require `debugEvents:true`. diff --git a/tests/check-public-docs.ts b/tests/check-public-docs.ts index b2f719e..beb4b2f 100644 --- a/tests/check-public-docs.ts +++ b/tests/check-public-docs.ts @@ -143,7 +143,7 @@ function checkPublicSurfaceOwnership(): void { requireFragments("README.md", readme, ["human/operator path", "What gets installed", "`/skill:pi-multiagent`", "Action rule of thumb", "minimum read-only run", "First successful `graphFile` run", "local-read-only-graph.json", "Do not put `action`, `runId`, nested `graphFile`", "short `runId` such as `r1`", "Cleanup is evidence deletion", "catalog` output is authoritative", "routing tags", "default built-in tool profiles"]); requireFragments("README.md", readme, ["Which document owns what", "full `agent_team` action/schema", "graph design ladder", "CONTRIBUTING.md", "normal persistent Pi sessions", "stored by Pi"]); requireFragments("skills/pi-multiagent/SKILL.md", skill, ["Action controls are strict", "Tool-call pseudo-schema", "Graph step object, used inside `graph.steps[]` only", "Do not send `{\"action\":\"step\"}`", "short process-local `runId`", "Tool profile decision matrix", "Authority policy and child runtime", "Extension tools", "Supervision and evidence", "Runtime limits", "Troubleshooting contract"]); - requireFragments("skills/pi-multiagent/SKILL.md", skill, ["all terminal step artifact metadata", "bounded task previews", "run_status.stepId", "Pushed notices are compact untrusted human receipts", "`agent_team:live` widget", "shared footer status row", "Assistant text previews require `preview:true`", "raw events require `debugEvents:true`", "agent.model", "agent.thinking", "outputLimit"]); + requireFragments("skills/pi-multiagent/SKILL.md", skill, ["all terminal step artifact metadata", "bounded task previews", "run_status.stepId", "Pushed notices are compact untrusted human receipts", "`agent_team:live` widget", "agent_team:footer-cost", "Assistant text previews require `preview:true`", "raw events require `debugEvents:true`", "agent.model", "agent.thinking", "outputLimit"]); requireFragments("skills/pi-multiagent/SKILL.md", skill, ["structured wait receipt", "suppressed non-error activity", "accepted-for-delivery transport", "source and file path are provenance only", "`package:validator` fails planning without effective `bash`", "`package:worker` fails planning without effective `edit` or `write`", "Planning fails before launch unless explicit callable web search/fetch grants match live catalog provenance"]); requireFragments("skills/pi-multiagent/SKILL.md", skill, ["--agent-team-subagent-skills enabled|disabled", "Parent abort before registration cancels setup", "Escape cancels or aborts the parent surface", "Non-final assistant evidence", "Recoverable child context overflow", "normal persistent Pi sessions", "does not pass `--no-session`", "child session metadata", "progress-watchdog", "read-only idempotent step may be retried once", "Queue observations may report queued, consumed, no-next-turn, or consumption-unproven"]); requireFragments("skills/pi-multiagent/SKILL.md", skill, ["Read controls belong only on `run_status` or `step_result`", "`# agent_team error`", "package `ok:false` is surfaced as a Pi tool-result error", "`steer` queues after the current child turn/tool calls", "`follow_up` waits until the child is quiescent", "duplicate `clientMessageId` values reuse the original receipt", "Graph `needs` / `after` gates are model dependencies, not human approval checkpoints"]); diff --git a/tests/rendering.test.ts b/tests/rendering.test.ts index e198d79..9663d40 100644 --- a/tests/rendering.test.ts +++ b/tests/rendering.test.ts @@ -14,10 +14,12 @@ const theme = { const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); test("production code routes live progress through the agent_team widget and notices", () => { - for (const file of productionFiles(join(packageRoot, "extensions", "multiagent"))) { + for (const file of productionFiles(join(packageRoot, "extensions", "multiagent", "src"))) { const source = readFileSync(file, "utf8"); assert.doesNotMatch(source, /\bctx\.ui\.setStatus\b/, `${file}: use agent_team:live widget and notices, not Pi's shared footer status row`); } + const entrypoint = readFileSync(join(packageRoot, "extensions", "multiagent", "index.ts"), "utf8"); + assert.doesNotMatch(entrypoint, /ctx\.ui\.setStatus\((?!FOOTER_COST_FLAG)/, "index.ts setStatus calls must use FOOTER_COST_FLAG"); }); test("renderAgentTeamCall summarizes detached actions", () => { @@ -33,7 +35,7 @@ test("renderAgentTeamResult reports catalog and run state", () => { const started = details("start", { run: run({ objective: "detached", liveStepIds: ["one"], sinkStepIds: ["one"], lastEvent: "one: start", counts: counts({ running: 1 }) }) }); const rendered = renderAgentTeamResult({ content: [], details: started }, { expanded: false, isPartial: false }, theme, undefined).render(120).join("\n"); assert.match(rendered, /agent_team started detached/); - assert.match(rendered, /0\/1 complete 1 working/); + assert.match(rendered, /0\/1 complete {2}1 working/); assert.match(rendered, /last update one: start/); assert.doesNotMatch(rendered, /objective|active=|sinks=|Can:|cursor|cleanup=true|updated|Artifact:/i); }); @@ -61,7 +63,7 @@ test("renderAgentTeamResult renders cancel as a human stop receipt", () => { }); const rendered = renderAgentTeamResult({ content: [], details: canceling }, { expanded: false, isPartial: false }, theme, undefined).render(120).join("\n"); assert.match(rendered, /agent_team stop requested r1/); - assert.match(rendered, /0\/4 complete 4 working/); + assert.match(rendered, /0\/4 complete {2}4 working/); assert.match(rendered, /working now runtime-safety prompt sent; \+3 more lanes/); assert.match(rendered, /last update tui-human: terminalizing \[canceled\]/); assert.doesNotMatch(rendered, /cancel canceling|objective|active=|sinks=/i); @@ -87,8 +89,8 @@ test("renderAgentTeamLiveRunsWidget surfaces active roles and queued work withou ], }); const rendered = renderAgentTeamLiveRunsWidget([live], theme).render(120).join("\n"); - assert.match(rendered, /1\/4 complete 2 working 1 queued/); - assert.match(rendered, /working now\n > scout writing\n > docs-auditor rg running/); + assert.match(rendered, /1\/4 complete {2}2 working {2}1 queued/); + assert.match(rendered, /working now\n {2}> scout writing\n {2}> docs-auditor rg running/); assert.match(rendered, /queued next synthesizer/); assert.doesNotMatch(rendered, /run_status|step_result|cleanup|cursor|debugEvents|Artifact:/i); }); @@ -103,8 +105,8 @@ test("renderAgentTeamLiveRunsWidget lets attention outrank objective text", () = }); const rendered = renderAgentTeamLiveRunsWidget([problem], theme).render(120).join("\n"); assert.match(rendered, /agent_team attention/); - assert.match(rendered, /4\/5 complete 1 failed 1 working/); - assert.match(rendered, /needs attention\n ! validator typecheck failed/); + assert.match(rendered, /4\/5 complete {2}1 failed {2}1 working/); + assert.match(rendered, /needs attention\n {2}! validator typecheck failed/); assert.doesNotMatch(rendered, /long objective/i); }); @@ -122,10 +124,10 @@ test("renderAgentTeamLiveRunsWidget distinguishes multiple live runs", () => { steps: [step({ id: "docs", agentRef: "package:docs-auditor", status: "running", lastActivity: "assistant writing" })], }); const rendered = renderAgentTeamLiveRunsWidget([first, second, third], theme).render(120).join("\n"); - assert.match(rendered, /agent_team 3 runs 1 need attention/); - assert.match(rendered, /! Release proof 1\/2 complete 1 failed 1 working validator failed: gate failed/); - assert.match(rendered, /> TUI rewrite 2\/3 complete 1 working reviewer writing/); - assert.match(rendered, /> Docs audit 0\/1 complete 1 working docs-auditor writing/); + assert.match(rendered, /agent_team 3 runs {2}1 need attention/); + assert.match(rendered, /! Release proof 1\/2 complete {2}1 failed {2}1 working validator failed: gate failed/); + assert.match(rendered, /> TUI rewrite 2\/3 complete {2}1 working reviewer writing/); + assert.match(rendered, /> Docs audit 0\/1 complete {2}1 working docs-auditor writing/); }); test("renderAgentTeamLiveRunsWidget fits narrow widths", () => {