Skip to content

Commit f2ac0a1

Browse files
committed
Fix: Emit TaskTokenUsageUpdated when tool usage changes
- Add hasToolUsageChanged() helper function to getApiMetrics.ts - Add toolUsageSnapshot property to Task.ts to track tool usage changes - Update saveClineMessages() to emit when either token OR tool usage changes - Update emitFinalTokenUsageUpdate() to also check tool usage changes - Add comprehensive tests for tool usage change detection This ensures final tool usage stats are captured on task abort even if token usage hasn't changed (e.g., when task is aborted before API request completes but tools were already executed).
1 parent 82902ba commit f2ac0a1

File tree

3 files changed

+285
-4
lines changed

3 files changed

+285
-4
lines changed

src/core/task/Task.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { combineApiRequests } from "../../shared/combineApiRequests"
6161
import { combineCommandSequences } from "../../shared/combineCommandSequences"
6262
import { t } from "../../i18n"
6363
import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/ExtensionMessage"
64-
import { getApiMetrics, hasTokenUsageChanged } from "../../shared/getApiMetrics"
64+
import { getApiMetrics, hasTokenUsageChanged, hasToolUsageChanged } from "../../shared/getApiMetrics"
6565
import { ClineAskResponse } from "../../shared/WebviewMessage"
6666
import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes"
6767
import { DiffStrategy, type ToolUse, type ToolParamName, toolParamNames } from "../../shared/tools"
@@ -322,6 +322,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
322322
private tokenUsageSnapshot?: TokenUsage
323323
private tokenUsageSnapshotAt?: number
324324

325+
// Tool Usage Cache
326+
private toolUsageSnapshot?: ToolUsage
327+
325328
// Token Usage Throttling
326329
private lastTokenUsageEmitTime?: number
327330
private readonly TOKEN_USAGE_EMIT_INTERVAL_MS = 2000 // 2 seconds
@@ -927,11 +930,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
927930

928931
const shouldEmitDueToThrottle = timeSinceLastEmit >= this.TOKEN_USAGE_EMIT_INTERVAL_MS
929932

930-
if (shouldEmitDueToThrottle && hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) {
933+
// Emit if throttle window allows AND either token usage or tool usage changed
934+
const tokenChanged = hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)
935+
const toolChanged = hasToolUsageChanged(this.toolUsage, this.toolUsageSnapshot)
936+
937+
if (shouldEmitDueToThrottle && (tokenChanged || toolChanged)) {
931938
this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage, this.toolUsage)
932939
this.lastTokenUsageEmitTime = now
933940
this.tokenUsageSnapshot = tokenUsage
934941
this.tokenUsageSnapshotAt = this.clineMessages.at(-1)?.ts
942+
// Deep copy tool usage for snapshot
943+
this.toolUsageSnapshot = JSON.parse(JSON.stringify(this.toolUsage))
935944
}
936945

937946
await this.providerRef.deref()?.updateTaskHistory(historyItem)
@@ -1856,11 +1865,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
18561865
*/
18571866
public emitFinalTokenUsageUpdate(): void {
18581867
const tokenUsage = this.getTokenUsage()
1859-
if (hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) {
1868+
const tokenChanged = hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)
1869+
const toolChanged = hasToolUsageChanged(this.toolUsage, this.toolUsageSnapshot)
1870+
1871+
if (tokenChanged || toolChanged) {
18601872
this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage, this.toolUsage)
18611873
this.tokenUsageSnapshot = tokenUsage
18621874
this.tokenUsageSnapshotAt = this.clineMessages.at(-1)?.ts
18631875
this.lastTokenUsageEmitTime = Date.now()
1876+
// Deep copy tool usage for snapshot
1877+
this.toolUsageSnapshot = JSON.parse(JSON.stringify(this.toolUsage))
18641878
}
18651879
}
18661880

src/core/task/__tests__/Task.throttle.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { RooCodeEventName, ProviderSettings, TokenUsage, ToolUsage } from "@roo-
22

33
import { Task } from "../Task"
44
import { ClineProvider } from "../../webview/ClineProvider"
5+
import { hasToolUsageChanged, hasTokenUsageChanged } from "../../../shared/getApiMetrics"
56

67
// Mock dependencies
78
vi.mock("../../webview/ClineProvider")
@@ -381,4 +382,238 @@ describe("Task token usage throttling", () => {
381382
// Should not have emitted again since token usage didn't change
382383
expect(secondEmitCount).toBe(firstEmitCount)
383384
})
385+
386+
test("should emit when tool usage changes even if token usage is the same", async () => {
387+
const { taskMetadata } = await import("../../task-persistence")
388+
389+
// Mock taskMetadata to return same token usage
390+
const constantTokenUsage: TokenUsage = {
391+
totalTokensIn: 100,
392+
totalTokensOut: 50,
393+
totalCost: 0.01,
394+
contextTokens: 150,
395+
totalCacheWrites: 0,
396+
totalCacheReads: 0,
397+
}
398+
399+
vi.mocked(taskMetadata).mockResolvedValue({
400+
historyItem: {
401+
id: "test-task-id",
402+
number: 1,
403+
task: "Test task",
404+
ts: Date.now(),
405+
totalCost: 0.01,
406+
tokensIn: 100,
407+
tokensOut: 50,
408+
},
409+
tokenUsage: constantTokenUsage,
410+
})
411+
412+
const emitSpy = vi.spyOn(task, "emit")
413+
414+
// Add first message - should emit
415+
await (task as any).addToClineMessages({
416+
ts: Date.now(),
417+
type: "say",
418+
say: "text",
419+
text: "Message 1",
420+
})
421+
422+
const firstEmitCount = emitSpy.mock.calls.filter(
423+
(call) => call[0] === RooCodeEventName.TaskTokenUsageUpdated,
424+
).length
425+
426+
// Wait for throttle period
427+
vi.advanceTimersByTime(2100)
428+
429+
// Change tool usage (token usage stays the same)
430+
task.toolUsage = {
431+
read_file: { attempts: 5, failures: 1 },
432+
}
433+
434+
// Add another message
435+
await (task as any).addToClineMessages({
436+
ts: Date.now(),
437+
type: "say",
438+
say: "text",
439+
text: "Message 2",
440+
})
441+
442+
const secondEmitCount = emitSpy.mock.calls.filter(
443+
(call) => call[0] === RooCodeEventName.TaskTokenUsageUpdated,
444+
).length
445+
446+
// Should have emitted because tool usage changed even though token usage didn't
447+
expect(secondEmitCount).toBeGreaterThan(firstEmitCount)
448+
})
449+
450+
test("should update toolUsageSnapshot when emission occurs", async () => {
451+
// Add initial message
452+
await (task as any).addToClineMessages({
453+
ts: Date.now(),
454+
type: "say",
455+
say: "text",
456+
text: "Message 1",
457+
})
458+
459+
// Initially toolUsageSnapshot should be set to current toolUsage (empty object)
460+
const initialSnapshot = (task as any).toolUsageSnapshot
461+
expect(initialSnapshot).toBeDefined()
462+
expect(Object.keys(initialSnapshot)).toHaveLength(0)
463+
464+
// Wait for throttle period
465+
vi.advanceTimersByTime(2100)
466+
467+
// Update tool usage
468+
task.toolUsage = {
469+
read_file: { attempts: 3, failures: 0 },
470+
write_to_file: { attempts: 2, failures: 1 },
471+
}
472+
473+
// Add another message
474+
await (task as any).addToClineMessages({
475+
ts: Date.now(),
476+
type: "say",
477+
say: "text",
478+
text: "Message 2",
479+
})
480+
481+
// Snapshot should be updated to match the new toolUsage
482+
const newSnapshot = (task as any).toolUsageSnapshot
483+
expect(newSnapshot).not.toBe(initialSnapshot)
484+
expect(newSnapshot.read_file).toEqual({ attempts: 3, failures: 0 })
485+
expect(newSnapshot.write_to_file).toEqual({ attempts: 2, failures: 1 })
486+
})
487+
488+
test("emitFinalTokenUsageUpdate should emit on tool usage change alone", async () => {
489+
const emitSpy = vi.spyOn(task, "emit")
490+
491+
// Set initial tool usage and simulate previous emission
492+
;(task as any).tokenUsageSnapshot = task.getTokenUsage()
493+
;(task as any).toolUsageSnapshot = {}
494+
495+
// Change tool usage
496+
task.toolUsage = {
497+
execute_command: { attempts: 1, failures: 0 },
498+
}
499+
500+
// Call emitFinalTokenUsageUpdate
501+
task.emitFinalTokenUsageUpdate()
502+
503+
// Should emit due to tool usage change
504+
expect(emitSpy).toHaveBeenCalledWith(
505+
RooCodeEventName.TaskTokenUsageUpdated,
506+
task.taskId,
507+
expect.any(Object),
508+
task.toolUsage,
509+
)
510+
})
511+
})
512+
513+
describe("hasToolUsageChanged", () => {
514+
test("should return true when snapshot is undefined and current has data", () => {
515+
const current: ToolUsage = {
516+
read_file: { attempts: 1, failures: 0 },
517+
}
518+
expect(hasToolUsageChanged(current, undefined)).toBe(true)
519+
})
520+
521+
test("should return false when both are empty", () => {
522+
expect(hasToolUsageChanged({}, {})).toBe(false)
523+
})
524+
525+
test("should return false when snapshot is undefined and current is empty", () => {
526+
expect(hasToolUsageChanged({}, undefined)).toBe(false)
527+
})
528+
529+
test("should return true when a new tool is added", () => {
530+
const current: ToolUsage = {
531+
read_file: { attempts: 1, failures: 0 },
532+
write_to_file: { attempts: 1, failures: 0 },
533+
}
534+
const snapshot: ToolUsage = {
535+
read_file: { attempts: 1, failures: 0 },
536+
}
537+
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
538+
})
539+
540+
test("should return true when attempts change", () => {
541+
const current: ToolUsage = {
542+
read_file: { attempts: 2, failures: 0 },
543+
}
544+
const snapshot: ToolUsage = {
545+
read_file: { attempts: 1, failures: 0 },
546+
}
547+
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
548+
})
549+
550+
test("should return true when failures change", () => {
551+
const current: ToolUsage = {
552+
read_file: { attempts: 1, failures: 1 },
553+
}
554+
const snapshot: ToolUsage = {
555+
read_file: { attempts: 1, failures: 0 },
556+
}
557+
expect(hasToolUsageChanged(current, snapshot)).toBe(true)
558+
})
559+
560+
test("should return false when nothing changed", () => {
561+
const current: ToolUsage = {
562+
read_file: { attempts: 3, failures: 1 },
563+
write_to_file: { attempts: 2, failures: 0 },
564+
}
565+
const snapshot: ToolUsage = {
566+
read_file: { attempts: 3, failures: 1 },
567+
write_to_file: { attempts: 2, failures: 0 },
568+
}
569+
expect(hasToolUsageChanged(current, snapshot)).toBe(false)
570+
})
571+
})
572+
573+
describe("hasTokenUsageChanged", () => {
574+
test("should return true when snapshot is undefined", () => {
575+
const current: TokenUsage = {
576+
totalTokensIn: 100,
577+
totalTokensOut: 50,
578+
totalCost: 0.01,
579+
contextTokens: 150,
580+
}
581+
expect(hasTokenUsageChanged(current, undefined)).toBe(true)
582+
})
583+
584+
test("should return true when totalTokensIn changes", () => {
585+
const current: TokenUsage = {
586+
totalTokensIn: 200,
587+
totalTokensOut: 50,
588+
totalCost: 0.01,
589+
contextTokens: 150,
590+
}
591+
const snapshot: TokenUsage = {
592+
totalTokensIn: 100,
593+
totalTokensOut: 50,
594+
totalCost: 0.01,
595+
contextTokens: 150,
596+
}
597+
expect(hasTokenUsageChanged(current, snapshot)).toBe(true)
598+
})
599+
600+
test("should return false when nothing changed", () => {
601+
const current: TokenUsage = {
602+
totalTokensIn: 100,
603+
totalTokensOut: 50,
604+
totalCost: 0.01,
605+
contextTokens: 150,
606+
totalCacheWrites: 10,
607+
totalCacheReads: 5,
608+
}
609+
const snapshot: TokenUsage = {
610+
totalTokensIn: 100,
611+
totalTokensOut: 50,
612+
totalCost: 0.01,
613+
contextTokens: 150,
614+
totalCacheWrites: 10,
615+
totalCacheReads: 5,
616+
}
617+
expect(hasTokenUsageChanged(current, snapshot)).toBe(false)
618+
})
384619
})

src/shared/getApiMetrics.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { TokenUsage, ClineMessage } from "@roo-code/types"
1+
import type { TokenUsage, ToolUsage, ToolName, ClineMessage } from "@roo-code/types"
22

33
export type ParsedApiReqStartedTextType = {
44
tokensIn: number
@@ -123,3 +123,35 @@ export function hasTokenUsageChanged(current: TokenUsage, snapshot?: TokenUsage)
123123

124124
return keysToCompare.some((key) => current[key] !== snapshot[key])
125125
}
126+
127+
/**
128+
* Check if tool usage has changed by comparing attempts and failures.
129+
* @param current - Current tool usage data
130+
* @param snapshot - Previous snapshot to compare against
131+
* @returns true if any tool's attempts/failures have changed or snapshot is undefined
132+
*/
133+
export function hasToolUsageChanged(current: ToolUsage, snapshot?: ToolUsage): boolean {
134+
if (!snapshot) {
135+
return Object.keys(current).length > 0
136+
}
137+
138+
const currentKeys = Object.keys(current) as ToolName[]
139+
const snapshotKeys = Object.keys(snapshot) as ToolName[]
140+
141+
// Check if number of tools changed
142+
if (currentKeys.length !== snapshotKeys.length) {
143+
return true
144+
}
145+
146+
// Check if any tool's stats changed
147+
return currentKeys.some((key) => {
148+
const currentTool = current[key]
149+
const snapshotTool = snapshot[key]
150+
151+
if (!snapshotTool || !currentTool) {
152+
return true
153+
}
154+
155+
return currentTool.attempts !== snapshotTool.attempts || currentTool.failures !== snapshotTool.failures
156+
})
157+
}

0 commit comments

Comments
 (0)