diff --git a/packages/core/src/task-detail/cloudToolChanges.test.ts b/packages/core/src/task-detail/cloudToolChanges.test.ts index 3b6aeeee2..2cbd13789 100644 --- a/packages/core/src/task-detail/cloudToolChanges.test.ts +++ b/packages/core/src/task-detail/cloudToolChanges.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; import { + cachedDiffStats, extractCloudFileContent, extractCloudToolChangedFiles, type ParsedToolCall, } from "./cloudToolChanges"; +function diffObj( + newText: string, + oldText: string, +): NonNullable[0]> { + return { type: "diff", path: "src/f.ts", newText, oldText }; +} + function toolCall(overrides: Partial): ParsedToolCall { return { toolCallId: overrides.toolCallId ?? "tc-1", @@ -62,6 +70,56 @@ describe("extractCloudToolChangedFiles", () => { expect(result).toHaveLength(1); expect(result[0].path).toBe("src/app.ts"); }); + + it.each([ + { + name: "new file counts all lines as added", + kind: "write" as const, + oldText: undefined, + newText: "a\nb\nc", + added: 3, + removed: 0, + }, + { + name: "modified file counts added and removed", + kind: "edit" as const, + oldText: "a\nb\nc", + newText: "a\nB\nc\nd", + added: 2, + removed: 1, + }, + { + name: "pure removal counts removed only", + kind: "edit" as const, + oldText: "a\nb\nc", + newText: "a", + added: 0, + removed: 2, + }, + ])("diff stats: $name", ({ kind, oldText, newText, added, removed }) => { + const calls = makeToolCalls( + toolCall({ + toolCallId: "tc", + kind, + locations: [{ path: "src/f.ts" }], + content: diffContent("src/f.ts", newText, oldText), + }), + ); + const [file] = extractCloudToolChangedFiles(calls); + expect(file.linesAdded).toBe(added); + expect(file.linesRemoved).toBe(removed); + }); + + it("memoizes diff stats by diff-object identity", () => { + const diff = diffObj("a\nB\nc", "a\nb\nc"); + const first = cachedDiffStats(diff); + expect(cachedDiffStats(diff)).toBe(first); + + const distinctButEqual = diffObj("a\nB\nc", "a\nb\nc"); + const recomputed = cachedDiffStats(distinctButEqual); + expect(recomputed).not.toBe(first); + expect(recomputed).toEqual(first); + }); }); describe("extractCloudFileContent", () => { diff --git a/packages/core/src/task-detail/cloudToolChanges.ts b/packages/core/src/task-detail/cloudToolChanges.ts index 9030cfcd2..6759aa6b8 100644 --- a/packages/core/src/task-detail/cloudToolChanges.ts +++ b/packages/core/src/task-detail/cloudToolChanges.ts @@ -138,6 +138,22 @@ function getDiffStats( return { added, removed }; } +const diffStatsCache = new WeakMap< + Extract, + { added?: number; removed?: number } +>(); + +export function cachedDiffStats( + diff: Extract | undefined, +): { added?: number; removed?: number } { + if (!diff) return {}; + const cached = diffStatsCache.get(diff); + if (cached) return cached; + const stats = getDiffStats(diff.oldText, diff.newText); + diffStatsCache.set(diff, stats); + return stats; +} + export interface CloudEventSummary { toolCalls: Map; } @@ -266,7 +282,7 @@ export function extractCloudToolChangedFiles( status: "deleted", }; } else { - const diffStats = getDiffStats(diff?.oldText, diff?.newText); + const diffStats = cachedDiffStats(diff); file = { path, status: kind === "write" && !diff?.oldText ? "added" : "modified",