From ad361faed757429f03ecfe84ae3c66040f78220a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 20 Jun 2025 17:51:20 +0000 Subject: [PATCH] Fixes #4948: Fix context tokens calculation causing doubling/halving - Fixed getApiMetrics to only use tokensIn for context calculation - Previously was incorrectly adding tokensOut + cacheWrites + cacheReads - This caused oscillating context display when condensing occurred - Added comprehensive tests to verify the fix --- src/shared/__tests__/getApiMetrics.spec.ts | 156 +++++++++++++++++++++ src/shared/getApiMetrics.ts | 6 +- 2 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/shared/__tests__/getApiMetrics.spec.ts diff --git a/src/shared/__tests__/getApiMetrics.spec.ts b/src/shared/__tests__/getApiMetrics.spec.ts new file mode 100644 index 0000000000..68ee57616a --- /dev/null +++ b/src/shared/__tests__/getApiMetrics.spec.ts @@ -0,0 +1,156 @@ +import { getApiMetrics } from "../getApiMetrics" +import type { ClineMessage } from "@roo-code/types" + +describe("getApiMetrics", () => { + it("should calculate context tokens correctly using only tokensIn", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "api_req_started", + text: JSON.stringify({ + request: "test request", + tokensIn: 1000, + tokensOut: 500, + cacheWrites: 200, + cacheReads: 100, + cost: 0.01, + }), + ts: Date.now(), + }, + ] + + const result = getApiMetrics(messages) + + // Context tokens should only include tokensIn, not the sum of all token types + expect(result.contextTokens).toBe(1000) + expect(result.totalTokensIn).toBe(1000) + expect(result.totalTokensOut).toBe(500) + expect(result.totalCacheWrites).toBe(200) + expect(result.totalCacheReads).toBe(100) + expect(result.totalCost).toBe(0.01) + }) + + it("should use newContextTokens from condense_context messages", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "api_req_started", + text: JSON.stringify({ + tokensIn: 2000, + tokensOut: 800, + }), + ts: Date.now() - 1000, + }, + { + type: "say", + say: "condense_context", + text: undefined, + contextCondense: { + summary: "Context was condensed", + cost: 0.02, + newContextTokens: 800, + prevContextTokens: 2000, + }, + ts: Date.now(), + }, + ] + + const result = getApiMetrics(messages) + + // Should use newContextTokens from the most recent condense_context message + expect(result.contextTokens).toBe(800) + expect(result.totalCost).toBe(0.02) // Only condense cost since api_req_started has no cost + }) + + it("should handle multiple API requests and use the most recent for context", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "api_req_started", + text: JSON.stringify({ + tokensIn: 1000, + tokensOut: 400, + cost: 0.01, + }), + ts: Date.now() - 2000, + }, + { + type: "say", + say: "api_req_started", + text: JSON.stringify({ + tokensIn: 1500, + tokensOut: 600, + cost: 0.015, + }), + ts: Date.now(), + }, + ] + + const result = getApiMetrics(messages) + + // Should use context tokens from the most recent API request + expect(result.contextTokens).toBe(1500) + expect(result.totalTokensIn).toBe(2500) // Sum of both requests + expect(result.totalTokensOut).toBe(1000) // Sum of both requests + expect(result.totalCost).toBe(0.025) // Sum of both costs + }) + + it("should handle missing or invalid JSON gracefully", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "api_req_started", + text: "invalid json", + ts: Date.now(), + }, + ] + + const result = getApiMetrics(messages) + + expect(result.contextTokens).toBe(0) + expect(result.totalTokensIn).toBe(0) + expect(result.totalTokensOut).toBe(0) + expect(result.totalCost).toBe(0) + }) + + it("should prioritize condense_context over api_req_started when both exist", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "api_req_started", + text: JSON.stringify({ + tokensIn: 2000, + tokensOut: 800, + }), + ts: Date.now() - 1000, + }, + { + type: "say", + say: "condense_context", + text: undefined, + contextCondense: { + summary: "Context was condensed", + cost: 0.02, + newContextTokens: 1200, + prevContextTokens: 2000, + }, + ts: Date.now() - 500, + }, + { + type: "say", + say: "api_req_started", + text: JSON.stringify({ + tokensIn: 1300, + tokensOut: 500, + }), + ts: Date.now(), + }, + ] + + const result = getApiMetrics(messages) + + // Should use the most recent message for context calculation + // In this case, the most recent api_req_started + expect(result.contextTokens).toBe(1300) + }) +}) diff --git a/src/shared/getApiMetrics.ts b/src/shared/getApiMetrics.ts index 49476fdbb6..8997216da5 100644 --- a/src/shared/getApiMetrics.ts +++ b/src/shared/getApiMetrics.ts @@ -72,8 +72,10 @@ export function getApiMetrics(messages: ClineMessage[]) { if (message.type === "say" && message.say === "api_req_started" && message.text) { try { const parsedText: ParsedApiReqStartedTextType = JSON.parse(message.text) - const { tokensIn, tokensOut, cacheWrites, cacheReads } = parsedText - result.contextTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0) + const { tokensIn } = parsedText + // Only use tokensIn for context calculation - tokensOut, cacheWrites, and cacheReads + // are not part of the context window usage + result.contextTokens = tokensIn || 0 } catch (error) { console.error("Error parsing JSON:", error) continue