diff --git a/apps/vscode/src/shared/combineApiRequests.test.ts b/apps/vscode/src/shared/combineApiRequests.test.ts new file mode 100644 index 0000000000..cecc7c3062 --- /dev/null +++ b/apps/vscode/src/shared/combineApiRequests.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "bun:test" +import { strict as assert } from "node:assert" +import type { ClineMessage } from "./ExtensionMessage" +import { combineApiRequests } from "./combineApiRequests" + +function started(ts: number, text: string) { + return { ts, type: "say", say: "api_req_started", text } as ClineMessage +} + +function finished(ts: number, text: string) { + return { ts, type: "say", say: "api_req_finished", text } as ClineMessage +} + +describe("combineApiRequests", () => { + it("merges a started request with its following finished payload", () => { + const messages: ClineMessage[] = [ + started(1, JSON.stringify({ request: "GET /api/data" })), + finished(2, JSON.stringify({ cost: 0.005, tokensIn: 10, tokensOut: 5 })), + ] + + const combined = combineApiRequests(messages) + + assert.equal(combined.length, 1) + assert.equal(combined[0]?.say, "api_req_started") + assert.deepEqual(JSON.parse(combined[0]?.text ?? "{}"), { + request: "GET /api/data", + cost: 0.005, + tokensIn: 10, + tokensOut: 5, + }) + }) + + it("keeps an api_req_started without a matching finished payload", () => { + const messages: ClineMessage[] = [started(1, JSON.stringify({ request: "A" }))] + + const combined = combineApiRequests(messages) + + assert.equal(combined.length, 1) + assert.deepEqual(JSON.parse(combined[0]?.text ?? "{}"), { request: "A" }) + }) + + it("does not throw on a malformed api_req_started payload and keeps the row", () => { + const messages: ClineMessage[] = [ + started(1, "{not-json"), + { ts: 2, type: "say", say: "text", text: "hello" } as ClineMessage, + ] + + const combined = combineApiRequests(messages) + + assert.equal(combined[0]?.text, "{not-json") + assert.equal(combined[1]?.text, "hello") + }) + + it("does not throw on a malformed api_req_finished payload", () => { + const messages: ClineMessage[] = [ + started(1, JSON.stringify({ request: "A", cost: 0.1 })), + finished(2, "{not-json"), + ] + + const combined = combineApiRequests(messages) + + // Pair is still consumed; the started row survives with its own payload. + assert.equal(combined.length, 1) + assert.deepEqual(JSON.parse(combined[0]?.text ?? "{}"), { request: "A", cost: 0.1 }) + }) +}) \ No newline at end of file diff --git a/apps/vscode/src/shared/combineApiRequests.ts b/apps/vscode/src/shared/combineApiRequests.ts index e017ec3435..48e05fbec9 100644 --- a/apps/vscode/src/shared/combineApiRequests.ts +++ b/apps/vscode/src/shared/combineApiRequests.ts @@ -23,12 +23,29 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] { for (let i = 0; i < messages.length; i++) { if (messages[i].type === "say" && messages[i].say === "api_req_started") { - const startedRequest = JSON.parse(messages[i].text || "{}") + // Malformed payloads are tolerated everywhere else that reads this row + // (see messageUtils, RequestStartRow, getApiMetrics) because this runs + // inside the render pipeline. A JSON.parse throw here would crash the + // whole chat view, so degrade to keeping the original row instead. + let startedRequest: Record + try { + startedRequest = JSON.parse(messages[i].text || "{}") + } catch { + continue + } + let j = i + 1 while (j < messages.length) { if (messages[j].type === "say" && messages[j].say === "api_req_finished") { - const finishedRequest = JSON.parse(messages[j].text || "{}") + let finishedRequest: Record + try { + finishedRequest = JSON.parse(messages[j].text || "{}") + } catch { + // Unparseable finish payload — nothing to merge, keep the + // started row as-is but still consume the pair. + finishedRequest = {} + } const combinedRequest = { ...startedRequest, ...finishedRequest,