From 9e419b3e869602b14e36901c7c97537b47550806 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 19:19:41 +0300 Subject: [PATCH] fix(cli): count subagent stats --- packages/opencode/src/cli/cmd/stats.ts | 21 ++++-- .../test/kilocode/stats-subagent-cost.test.ts | 69 +++++++++++++++---- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 8c23e8b276..18f27a8f50 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,8 +1,9 @@ import type { Argv } from "yargs" +import type { MessageV2 } from "../../session/message-v2" // kilocode_change import { cmd } from "./cmd" import { Session } from "../../session" import { bootstrap } from "../bootstrap" -import { Database, isNull } from "../../storage" // kilocode_change - isNull for root session filter +import { Database } from "../../storage" import { SessionTable } from "../../session/session.sql" import { Project } from "../../project" import { Instance } from "../../project/instance" @@ -89,10 +90,7 @@ async function getCurrentProject(): Promise { } async function getAllSessions(): Promise { - // kilocode_change start - exclude subagent (child) sessions; their cost is already propagated into the - // parent's tool-wrapper assistant message, so summing both would double-count (#6321) - const rows = Database.use((db) => db.select().from(SessionTable).where(isNull(SessionTable.parent_id)).all()) - // kilocode_change end + const rows = Database.use((db) => db.select().from(SessionTable).all()) return rows.map((row) => Session.fromRow(row)) } @@ -196,7 +194,16 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin for (const message of messages) { if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 + // kilocode_change start - count propagated subagent cost once but keep child model stats (#6321) + const cost = (() => { + const parts = message.parts.filter( + (part: MessageV2.Part): part is MessageV2.StepFinishPart => part.type === "step-finish", + ) + if (parts.length === 0) return message.info.cost || 0 + return parts.reduce((sum: number, part: MessageV2.StepFinishPart) => sum + part.cost, 0) + })() + if (!session.parentID) sessionCost += message.info.cost || 0 + // kilocode_change end const modelKey = `${message.info.providerID}/${message.info.modelID}` if (!sessionModelUsage[modelKey]) { @@ -207,7 +214,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin } } sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 + sessionModelUsage[modelKey].cost += cost if (message.info.tokens) { sessionTokens.input += message.info.tokens.input || 0 diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts index fd01af6bbf..f4e5d31100 100644 --- a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -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) }, }) })