Skip to content

Commit a9e3a25

Browse files
committed
feat(dashboard): compute missing costs at query time and fix session grouping
1 parent 509346d commit a9e3a25

6 files changed

Lines changed: 523 additions & 31 deletions

File tree

src/core/webview/usageStatsMessageHandler.ts

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { StatsQuery as StatsQuerySchema } from "@roo-code/types"
1616
import type { ClineProvider } from "./ClineProvider"
1717
import type { UsageStatsService, JsonExport } from "../../services/stats"
1818
import { StatsServiceError } from "../../services/stats"
19+
import { getEffectiveCost } from "../../services/stats/costRecalculation"
1920
import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export"
2021
import { readTaskMessages } from "../task-persistence/taskMessages"
2122

@@ -453,10 +454,63 @@ async function deriveSessionTitle(taskId: string, globalStoragePath: string): Pr
453454
}
454455

455456
/**
456-
* Groups usage events by `taskId` and produces a {@link SessionSummary} for
457-
* each group. The summary uses the first event's model/provider/mode as
458-
* representative values (a session may span multiple models, but the first
459-
* event is a reasonable proxy for display purposes).
457+
* Resolves the root task ID for a usage event.
458+
*
459+
* Feature 2: Sessions should be grouped by conversation session (root task),
460+
* not by individual subtask. A subtask has a `parentTaskId` pointing to its
461+
* parent. By following the parent chain, we can group all subtasks under
462+
* their root conversation session.
463+
*
464+
* Since the event only carries its immediate `parentTaskId` (not the full
465+
* chain), we build a parent→children map from the event set and walk up
466+
* the chain. If an event has no `parentTaskId`, it IS the root.
467+
*
468+
* @param event The usage event to resolve.
469+
* @param parentMap Map of taskId → parentTaskId (built from the event set).
470+
* @returns The root task ID for grouping.
471+
*/
472+
function resolveRootTaskId(event: UsageEventV1, parentMap: Map<string, string | undefined>): string {
473+
let current = event.taskId
474+
const visited = new Set<string>() // Guard against cycles
475+
476+
while (!visited.has(current)) {
477+
visited.add(current)
478+
const parent = parentMap.get(current)
479+
if (!parent) break // No parent → this is the root
480+
current = parent
481+
}
482+
483+
return current
484+
}
485+
486+
/**
487+
* Builds a map of taskId → parentTaskId from a set of usage events.
488+
* This allows resolving the root task for any event in the set.
489+
*/
490+
function buildParentMap(events: UsageEventV1[]): Map<string, string | undefined> {
491+
const parentMap = new Map<string, string | undefined>()
492+
for (const event of events) {
493+
if (!parentMap.has(event.taskId)) {
494+
parentMap.set(event.taskId, event.parentTaskId)
495+
}
496+
}
497+
return parentMap
498+
}
499+
500+
/**
501+
* Groups usage events by their root conversation session and produces a
502+
* {@link SessionSummary} for each group.
503+
*
504+
* Feature 2: Events are grouped by root task ID (following `parentTaskId`
505+
* chains) so that subtasks appear under their parent conversation session.
506+
* If an event has no `parentTaskId`, it is its own root.
507+
*
508+
* Feature 1: Missing `costUsd` values are computed on-the-fly using the
509+
* model's pricing info. The NDJSON store is never modified.
510+
*
511+
* The summary uses the first event's model/provider/mode as representative
512+
* values (a session may span multiple models, but the first event is a
513+
* reasonable proxy for display purposes).
460514
*
461515
* @param events Filtered usage events (already scoped to the requested time
462516
* range and `includeCancelled` policy).
@@ -466,14 +520,18 @@ async function buildSessionSummaries(
466520
events: UsageEventV1[],
467521
globalStoragePath: string,
468522
): Promise<SessionSummary[]> {
469-
// Group events by taskId, preserving insertion order for determinism.
523+
// Feature 2: Build parent map and group by root task ID.
524+
const parentMap = buildParentMap(events)
525+
526+
// Group events by root taskId, preserving insertion order for determinism.
470527
const groups = new Map<string, UsageEventV1[]>()
471528
for (const event of events) {
472-
const list = groups.get(event.taskId)
529+
const rootTaskId = resolveRootTaskId(event, parentMap)
530+
const list = groups.get(rootTaskId)
473531
if (list) {
474532
list.push(event)
475533
} else {
476-
groups.set(event.taskId, [event])
534+
groups.set(rootTaskId, [event])
477535
}
478536
}
479537

@@ -491,11 +549,12 @@ async function buildSessionSummaries(
491549
const last = sorted[sorted.length - 1]
492550

493551
// Aggregate totals across all events in the task.
552+
// Feature 1: Use getEffectiveCost to compute missing costs on-the-fly.
494553
let totalTokens = 0
495554
let totalCost = 0
496555
for (const ev of sorted) {
497556
totalTokens += ev.usage.totalTokens?.value ?? 0
498-
totalCost += ev.usage.costUsd?.value ?? 0
557+
totalCost += getEffectiveCost(ev)
499558
}
500559

501560
const title = await deriveSessionTitle(taskId, globalStoragePath)
@@ -635,7 +694,8 @@ function mapEventToApiCall(event: UsageEventV1, index: number): APICallRecord {
635694
cacheReadTokens: event.usage.cacheReadTokens?.value ?? 0,
636695
cacheWriteTokens: event.usage.cacheWriteTokens?.value ?? 0,
637696
reasoningTokens: event.usage.reasoningTokens?.value ?? 0,
638-
costUsd: event.usage.costUsd?.value ?? 0,
697+
// Feature 1: Compute missing cost on-the-fly from model pricing.
698+
costUsd: getEffectiveCost(event),
639699
status: event.status,
640700
model: event.model,
641701
}
@@ -667,11 +727,12 @@ async function buildSessionDetail(
667727
const last = sorted[sorted.length - 1]
668728

669729
// Aggregate totals across all events in the task.
730+
// Feature 1: Use getEffectiveCost to compute missing costs on-the-fly.
670731
let totalTokens = 0
671732
let totalCost = 0
672733
for (const ev of sorted) {
673734
totalTokens += ev.usage.totalTokens?.value ?? 0
674-
totalCost += ev.usage.costUsd?.value ?? 0
735+
totalCost += getEffectiveCost(ev)
675736
}
676737

677738
const title = await deriveSessionTitle(taskId, globalStoragePath)
@@ -760,8 +821,12 @@ export async function handleGetDashboardSessionDetail(
760821
const exportData = await service.exportStats(allQuery, "json")
761822
const allEvents: UsageEventV1[] = (exportData as JsonExport).events ?? []
762823

763-
// Filter to the requested task.
764-
const taskEvents = allEvents.filter((ev) => ev.taskId === taskId)
824+
// Feature 2: Filter to the requested root task AND its subtasks.
825+
// The session list groups events by root task ID, so clicking a
826+
// session row passes the root task ID. We need to include events
827+
// from all subtasks whose root resolves to this taskId.
828+
const parentMap = buildParentMap(allEvents)
829+
const taskEvents = allEvents.filter((ev) => resolveRootTaskId(ev, parentMap) === taskId)
765830

766831
if (taskEvents.length === 0) {
767832
// No events for this task — return an empty detail rather than an

src/services/stats/UsageAggregator.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
UsageValueSource,
88
} from "@roo-code/types"
99

10+
import { getEffectiveCost, computeEventCost } from "./costRecalculation"
11+
1012
// ── Types ───────────────────────────────────────────────────────────────────
1113

1214
/** Internal event representation used for aggregation (UsageEventV1 + derived fields) */
@@ -395,24 +397,33 @@ export class UsageAggregator {
395397
case "status":
396398
return [event.status]
397399
case "source": {
398-
// Separate by the source of costUsd
399-
// If the event has costUsd, use its source; otherwise "unknown"
400-
const sources = new Set<string>()
401-
if (event.usage.costUsd) {
402-
sources.add(event.usage.costUsd.source)
403-
}
404-
// Also consider the source of input/output tokens
405-
if (event.usage.inputTokens) {
406-
sources.add(event.usage.inputTokens.source)
407-
}
408-
if (event.usage.outputTokens) {
409-
sources.add(event.usage.outputTokens.source)
400+
// Separate by the source of costUsd.
401+
// Feature 1: If the event has no costUsd but the cost can be
402+
// computed on-the-fly from model pricing, treat the source as
403+
// "estimated" (since it is derived, not provider-reported).
404+
const sources = new Set<string>()
405+
if (event.usage.costUsd) {
406+
sources.add(event.usage.costUsd.source)
407+
} else {
408+
// Check if cost can be computed; if so, mark as "estimated".
409+
// Otherwise the source remains "unknown".
410+
const computedCost = computeEventCost(event)
411+
if (computedCost > 0) {
412+
sources.add("estimated")
413+
}
414+
}
415+
// Also consider the source of input/output tokens
416+
if (event.usage.inputTokens) {
417+
sources.add(event.usage.inputTokens.source)
418+
}
419+
if (event.usage.outputTokens) {
420+
sources.add(event.usage.outputTokens.source)
421+
}
422+
if (sources.size === 0) {
423+
sources.add("unknown")
424+
}
425+
return Array.from(sources)
410426
}
411-
if (sources.size === 0) {
412-
sources.add("unknown")
413-
}
414-
return Array.from(sources)
415-
}
416427
default:
417428
return []
418429
}
@@ -451,7 +462,9 @@ export class UsageAggregator {
451462
const cacheWriteTokens = this.extractValue(event.usage.cacheWriteTokens)
452463
const reasoningTokens = this.extractValue(event.usage.reasoningTokens)
453464
const totalTokens = this.extractValue(event.usage.totalTokens)
454-
const costUsd = this.extractValue(event.usage.costUsd)
465+
// Feature 1: If costUsd is missing on old events, compute it on-the-fly
466+
// from the model's pricing info. Never modifies the stored event.
467+
const costUsd = getEffectiveCost(event)
455468

456469
// Inclusion semantics check
457470
const hasUnknownInclusion =

src/services/stats/__tests__/UsageAggregator.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,12 @@ describe("UsageAggregator", () => {
491491
expect(result.totals.cacheWriteTokens).toBe(0)
492492
expect(result.totals.reasoningTokens).toBe(0)
493493
expect(result.totals.totalTokens).toBe(0)
494-
expect(result.totals.costUsd).toBe(0)
494+
// Feature 1: When costUsd is missing, the aggregator now computes
495+
// the cost on-the-fly from the model's pricing info. The default
496+
// test event uses provider "anthropic" + model "claude-sonnet-4-20250514"
497+
// with 1000 input tokens. Anthropic pricing: $3/1M input tokens →
498+
// 1000 × 3 / 1_000_000 = 0.003.
499+
expect(result.totals.costUsd).toBeCloseTo(0.003, 5)
495500
})
496501
})
497502

0 commit comments

Comments
 (0)