Skip to content

Commit f48ea38

Browse files
authored
Feat/issue 5376 aggregate subtask costs (#10757)
1 parent f2b16d4 commit f48ea38

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+773
-3
lines changed

packages/types/src/vscode-extension-host.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export interface ExtensionMessage {
9494
| "claudeCodeRateLimits"
9595
| "customToolsResult"
9696
| "modes"
97+
| "taskWithAggregatedCosts"
9798
text?: string
9899
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
99100
checkpointWarning?: {
@@ -182,6 +183,13 @@ export interface ExtensionMessage {
182183
stepIndex?: number // For browserSessionNavigate: the target step index to display
183184
tools?: SerializedCustomToolDefinition[] // For customToolsResult
184185
modes?: { slug: string; name: string }[] // For modes response
186+
aggregatedCosts?: {
187+
// For taskWithAggregatedCosts response
188+
totalCost: number
189+
ownCost: number
190+
childrenCost: number
191+
}
192+
historyItem?: HistoryItem
185193
}
186194

187195
export type ExtensionState = Pick<
@@ -498,6 +506,7 @@ export interface WebviewMessage {
498506
| "getDismissedUpsells"
499507
| "updateSettings"
500508
| "allowedCommands"
509+
| "getTaskWithAggregatedCosts"
501510
| "deniedCommands"
502511
| "killBrowserSession"
503512
| "openBrowserSessionPanel"

src/core/webview/ClineProvider.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
4848
getModelId,
4949
} from "@roo-code/types"
50+
import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts"
5051
import { TelemetryService } from "@roo-code/telemetry"
5152
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
5253

@@ -1705,6 +1706,20 @@ export class ClineProvider
17051706
throw new Error("Task not found")
17061707
}
17071708

1709+
async getTaskWithAggregatedCosts(taskId: string): Promise<{
1710+
historyItem: HistoryItem
1711+
aggregatedCosts: AggregatedCosts
1712+
}> {
1713+
const { historyItem } = await this.getTaskWithId(taskId)
1714+
1715+
const aggregatedCosts = await aggregateTaskCostsRecursive(taskId, async (id: string) => {
1716+
const result = await this.getTaskWithId(id)
1717+
return result.historyItem
1718+
})
1719+
1720+
return { historyItem, aggregatedCosts }
1721+
}
1722+
17081723
async showTaskWithId(id: string) {
17091724
if (id !== this.getCurrentTask()?.taskId) {
17101725
// Non-current task.
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { aggregateTaskCostsRecursive } from "../aggregateTaskCosts.js"
3+
import type { HistoryItem } from "@roo-code/types"
4+
5+
describe("aggregateTaskCostsRecursive", () => {
6+
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
7+
8+
beforeEach(() => {
9+
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
10+
})
11+
12+
it("should calculate cost for task with no children", async () => {
13+
const mockHistory: Record<string, HistoryItem> = {
14+
"task-1": {
15+
id: "task-1",
16+
totalCost: 1.5,
17+
childIds: [],
18+
} as unknown as HistoryItem,
19+
}
20+
21+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
22+
23+
const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory)
24+
25+
expect(result.ownCost).toBe(1.5)
26+
expect(result.childrenCost).toBe(0)
27+
expect(result.totalCost).toBe(1.5)
28+
expect(result.childBreakdown).toEqual({})
29+
})
30+
31+
it("should calculate cost for task with undefined childIds", async () => {
32+
const mockHistory: Record<string, HistoryItem> = {
33+
"task-1": {
34+
id: "task-1",
35+
totalCost: 2.0,
36+
// childIds is undefined
37+
} as unknown as HistoryItem,
38+
}
39+
40+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
41+
42+
const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory)
43+
44+
expect(result.ownCost).toBe(2.0)
45+
expect(result.childrenCost).toBe(0)
46+
expect(result.totalCost).toBe(2.0)
47+
expect(result.childBreakdown).toEqual({})
48+
})
49+
50+
it("should aggregate parent with one child", async () => {
51+
const mockHistory: Record<string, HistoryItem> = {
52+
parent: {
53+
id: "parent",
54+
totalCost: 1.0,
55+
childIds: ["child-1"],
56+
} as unknown as HistoryItem,
57+
"child-1": {
58+
id: "child-1",
59+
totalCost: 0.5,
60+
childIds: [],
61+
} as unknown as HistoryItem,
62+
}
63+
64+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
65+
66+
const result = await aggregateTaskCostsRecursive("parent", getTaskHistory)
67+
68+
expect(result.ownCost).toBe(1.0)
69+
expect(result.childrenCost).toBe(0.5)
70+
expect(result.totalCost).toBe(1.5)
71+
expect(result.childBreakdown).toHaveProperty("child-1")
72+
const child1 = result.childBreakdown?.["child-1"]
73+
expect(child1).toBeDefined()
74+
expect(child1!.totalCost).toBe(0.5)
75+
})
76+
77+
it("should aggregate parent with multiple children", async () => {
78+
const mockHistory: Record<string, HistoryItem> = {
79+
parent: {
80+
id: "parent",
81+
totalCost: 1.0,
82+
childIds: ["child-1", "child-2", "child-3"],
83+
} as unknown as HistoryItem,
84+
"child-1": {
85+
id: "child-1",
86+
totalCost: 0.5,
87+
childIds: [],
88+
} as unknown as HistoryItem,
89+
"child-2": {
90+
id: "child-2",
91+
totalCost: 0.75,
92+
childIds: [],
93+
} as unknown as HistoryItem,
94+
"child-3": {
95+
id: "child-3",
96+
totalCost: 0.25,
97+
childIds: [],
98+
} as unknown as HistoryItem,
99+
}
100+
101+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
102+
103+
const result = await aggregateTaskCostsRecursive("parent", getTaskHistory)
104+
105+
expect(result.ownCost).toBe(1.0)
106+
expect(result.childrenCost).toBe(1.5) // 0.5 + 0.75 + 0.25
107+
expect(result.totalCost).toBe(2.5)
108+
expect(Object.keys(result.childBreakdown || {})).toHaveLength(3)
109+
})
110+
111+
it("should recursively aggregate multi-level hierarchy", async () => {
112+
const mockHistory: Record<string, HistoryItem> = {
113+
parent: {
114+
id: "parent",
115+
totalCost: 1.0,
116+
childIds: ["child"],
117+
} as unknown as HistoryItem,
118+
child: {
119+
id: "child",
120+
totalCost: 0.5,
121+
childIds: ["grandchild"],
122+
} as unknown as HistoryItem,
123+
grandchild: {
124+
id: "grandchild",
125+
totalCost: 0.25,
126+
childIds: [],
127+
} as unknown as HistoryItem,
128+
}
129+
130+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
131+
132+
const result = await aggregateTaskCostsRecursive("parent", getTaskHistory)
133+
134+
expect(result.ownCost).toBe(1.0)
135+
expect(result.childrenCost).toBe(0.75) // child (0.5) + grandchild (0.25)
136+
expect(result.totalCost).toBe(1.75)
137+
138+
// Verify child breakdown
139+
const child = result.childBreakdown?.["child"]
140+
expect(child).toBeDefined()
141+
expect(child!.ownCost).toBe(0.5)
142+
expect(child!.childrenCost).toBe(0.25)
143+
expect(child!.totalCost).toBe(0.75)
144+
145+
// Verify grandchild breakdown
146+
const grandchild = child!.childBreakdown?.["grandchild"]
147+
expect(grandchild).toBeDefined()
148+
expect(grandchild!.ownCost).toBe(0.25)
149+
expect(grandchild!.childrenCost).toBe(0)
150+
expect(grandchild!.totalCost).toBe(0.25)
151+
})
152+
153+
it("should detect and prevent circular references", async () => {
154+
const mockHistory: Record<string, HistoryItem> = {
155+
"task-a": {
156+
id: "task-a",
157+
totalCost: 1.0,
158+
childIds: ["task-b"],
159+
} as unknown as HistoryItem,
160+
"task-b": {
161+
id: "task-b",
162+
totalCost: 0.5,
163+
childIds: ["task-a"], // Circular reference back to task-a
164+
} as unknown as HistoryItem,
165+
}
166+
167+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
168+
169+
const result = await aggregateTaskCostsRecursive("task-a", getTaskHistory)
170+
171+
// Should still process task-b but ignore the circular reference
172+
expect(result.ownCost).toBe(1.0)
173+
expect(result.childrenCost).toBe(0.5) // Only task-b's own cost, circular ref returns 0
174+
expect(result.totalCost).toBe(1.5)
175+
176+
// Verify warning was logged
177+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Circular reference detected: task-a"))
178+
})
179+
180+
it("should handle missing task gracefully", async () => {
181+
const mockHistory: Record<string, HistoryItem> = {
182+
parent: {
183+
id: "parent",
184+
totalCost: 1.0,
185+
childIds: ["nonexistent-child"],
186+
} as unknown as HistoryItem,
187+
}
188+
189+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
190+
191+
const result = await aggregateTaskCostsRecursive("parent", getTaskHistory)
192+
193+
expect(result.ownCost).toBe(1.0)
194+
expect(result.childrenCost).toBe(0) // Missing child contributes 0
195+
expect(result.totalCost).toBe(1.0)
196+
197+
// Verify warning was logged
198+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task nonexistent-child not found"))
199+
})
200+
201+
it("should return zero costs for completely missing task", async () => {
202+
const mockHistory: Record<string, HistoryItem> = {}
203+
204+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
205+
206+
const result = await aggregateTaskCostsRecursive("nonexistent", getTaskHistory)
207+
208+
expect(result.ownCost).toBe(0)
209+
expect(result.childrenCost).toBe(0)
210+
expect(result.totalCost).toBe(0)
211+
212+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("Task nonexistent not found"))
213+
})
214+
215+
it("should handle task with null totalCost", async () => {
216+
const mockHistory: Record<string, HistoryItem> = {
217+
"task-1": {
218+
id: "task-1",
219+
totalCost: null as unknown as number, // Explicitly null (invalid type in prod)
220+
childIds: [],
221+
} as unknown as HistoryItem,
222+
}
223+
224+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
225+
226+
const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory)
227+
228+
expect(result.ownCost).toBe(0)
229+
expect(result.childrenCost).toBe(0)
230+
expect(result.totalCost).toBe(0)
231+
})
232+
233+
it("should handle task with undefined totalCost", async () => {
234+
const mockHistory: Record<string, HistoryItem> = {
235+
"task-1": {
236+
id: "task-1",
237+
// totalCost is undefined
238+
childIds: [],
239+
} as unknown as HistoryItem,
240+
}
241+
242+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
243+
244+
const result = await aggregateTaskCostsRecursive("task-1", getTaskHistory)
245+
246+
expect(result.ownCost).toBe(0)
247+
expect(result.childrenCost).toBe(0)
248+
expect(result.totalCost).toBe(0)
249+
})
250+
251+
it("should handle complex hierarchy with mixed costs", async () => {
252+
const mockHistory: Record<string, HistoryItem> = {
253+
root: {
254+
id: "root",
255+
totalCost: 2.5,
256+
childIds: ["child-1", "child-2"],
257+
} as unknown as HistoryItem,
258+
"child-1": {
259+
id: "child-1",
260+
totalCost: 1.2,
261+
childIds: ["grandchild-1", "grandchild-2"],
262+
} as unknown as HistoryItem,
263+
"child-2": {
264+
id: "child-2",
265+
totalCost: 0.8,
266+
childIds: [],
267+
} as unknown as HistoryItem,
268+
"grandchild-1": {
269+
id: "grandchild-1",
270+
totalCost: 0.3,
271+
childIds: [],
272+
} as unknown as HistoryItem,
273+
"grandchild-2": {
274+
id: "grandchild-2",
275+
totalCost: 0.15,
276+
childIds: [],
277+
} as unknown as HistoryItem,
278+
}
279+
280+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
281+
282+
const result = await aggregateTaskCostsRecursive("root", getTaskHistory)
283+
284+
expect(result.ownCost).toBe(2.5)
285+
// child-1: 1.2 + 0.3 + 0.15 = 1.65
286+
// child-2: 0.8
287+
// Total children: 2.45
288+
expect(result.childrenCost).toBe(2.45)
289+
expect(result.totalCost).toBe(4.95) // 2.5 + 2.45
290+
})
291+
292+
it("should handle siblings without cross-contamination", async () => {
293+
const mockHistory: Record<string, HistoryItem> = {
294+
parent: {
295+
id: "parent",
296+
totalCost: 1.0,
297+
childIds: ["sibling-1", "sibling-2"],
298+
} as unknown as HistoryItem,
299+
"sibling-1": {
300+
id: "sibling-1",
301+
totalCost: 0.5,
302+
childIds: ["nephew"],
303+
} as unknown as HistoryItem,
304+
"sibling-2": {
305+
id: "sibling-2",
306+
totalCost: 0.3,
307+
childIds: ["nephew"], // Same child ID as sibling-1
308+
} as unknown as HistoryItem,
309+
nephew: {
310+
id: "nephew",
311+
totalCost: 0.1,
312+
childIds: [],
313+
} as unknown as HistoryItem,
314+
}
315+
316+
const getTaskHistory = vi.fn(async (id: string) => mockHistory[id])
317+
318+
const result = await aggregateTaskCostsRecursive("parent", getTaskHistory)
319+
320+
// Both siblings should independently count nephew
321+
// sibling-1: 0.5 + 0.1 = 0.6
322+
// sibling-2: 0.3 + 0.1 = 0.4
323+
// Total: 1.0 + 0.6 + 0.4 = 2.0
324+
expect(result.totalCost).toBe(2.0)
325+
})
326+
})

0 commit comments

Comments
 (0)