fix(cli): count subagent stats

This commit is contained in:
Alex Alecu
2026-04-24 19:19:41 +03:00
parent 1230a3af8b
commit bc90a50597
2 changed files with 68 additions and 22 deletions
@@ -1,8 +1,7 @@
// Verifies `kilo stats` does not double-count subagent cost. The task tool
// propagates each child session's total cost up to the parent's tool-wrapper
// assistant message. If stats summed every session indiscriminately, that
// propagated cost would appear in both the parent wrapper and the child's
// own messages. The aggregator is now filtered to root sessions (#6321).
// Verifies `kilo stats` does not double-count subagent cost while still
// including child-session messages, tokens, tools, and model usage. The task
// tool propagates each child session's total cost up to the parent's
// tool-wrapper assistant message (#6321).
import { afterEach, describe, expect, test } from "bun:test"
import { aggregateSessionStats } from "../../src/cli/cmd/stats"
@@ -10,7 +9,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { Instance } from "../../src/project/instance"
import { ProviderID, ModelID } from "../../src/provider/schema"
import { Session } from "../../src/session"
import { MessageID } from "../../src/session/schema"
import { MessageID, PartID } from "../../src/session/schema"
import { Log } from "../../src/util"
import { tmpdir } from "../fixture/fixture"
@@ -42,8 +41,40 @@ function assistant(sessionID: string, parentID: string, cost: number): MessageV2
}
}
async function step(sessionID: string, messageID: string, cost: number) {
await Session.updatePart({
id: PartID.ascending(),
messageID: messageID as any,
sessionID: sessionID as any,
type: "step-finish",
reason: "stop",
cost,
tokens: { total: 15, input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
})
}
async function tool(sessionID: string, messageID: string) {
const time = Date.now()
await Session.updatePart({
id: PartID.ascending(),
messageID: messageID as any,
sessionID: sessionID as any,
type: "tool",
callID: "call_1",
tool: "bash",
state: {
status: "completed",
input: {},
output: "ok",
title: "bash",
metadata: {},
time: { start: time, end: time },
},
})
}
describe("stats subagent cost", () => {
test("totalCost excludes children whose cost was propagated into the parent", async () => {
test("counts child usage without double-counting propagated cost", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
@@ -51,7 +82,6 @@ describe("stats subagent cost", () => {
const parent = await Session.create({ title: "root" })
const child = await Session.create({ parentID: parent.id, title: "subagent" })
// The parent's tool-wrapper assistant message shows the propagated total (own LLM + child).
const userMsg = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -60,8 +90,9 @@ describe("stats subagent cost", () => {
model: ref,
time: { created: Date.now() },
} as any)
await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5))
// The child session independently records its own LLM cost.
const parentMsg = await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5))
await step(parent.id, parentMsg.id, 1)
const childUser = await Session.updateMessage({
id: MessageID.ascending(),
role: "user",
@@ -70,14 +101,22 @@ describe("stats subagent cost", () => {
model: ref,
time: { created: Date.now() },
} as any)
await Session.updateMessage(assistant(child.id, childUser.id, 0.5))
const childMsg = await Session.updateMessage(assistant(child.id, childUser.id, 0.5))
await step(child.id, childMsg.id, 0.5)
await tool(child.id, childMsg.id)
const stats = await aggregateSessionStats()
// Without the fix, totalCost would be 1.5 + 0.5 = 2.0 (child counted twice).
// With the fix, only the parent (root) session contributes: 1.5 (which already
// includes the 0.5 propagated from the child).
const model = stats.modelUsage["test/test-model"]!
expect(stats.totalCost).toBeCloseTo(1.5, 6)
expect(stats.totalSessions).toBe(1)
expect(stats.totalSessions).toBe(2)
expect(stats.totalMessages).toBe(4)
expect(stats.totalTokens.input).toBe(20)
expect(stats.totalTokens.output).toBe(10)
expect(stats.toolUsage.bash).toBe(1)
expect(model.messages).toBe(2)
expect(model.tokens.input).toBe(20)
expect(model.tokens.output).toBe(10)
expect(model.cost).toBeCloseTo(1.5, 6)
},
})
})