Path: blob/main/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { URI } from '../../../../base/common/uri.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';8import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js';9import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js';10import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js';11import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js';1213const TEST_SESSION_URI = 'session://test-session';1415const createTestDiffService = () => new TestDiffComputeService();1617function fileDiff(path: string, added: number, removed: number): ISessionFileDiff {18const uri = URI.file(path).toString();19return { after: { uri, content: { uri } }, diff: { added, removed } };20}2122function getDiffUri(diff: ISessionFileDiff): string | undefined {23return diff.after?.uri ?? diff.before?.uri;24}2526interface ISimpleDiff {27uri: string | undefined;28added: number;29removed: number;30}3132function simplify(diff: ISessionFileDiff): ISimpleDiff {33return {34uri: getDiffUri(diff),35added: diff.diff?.added ?? 0,36removed: diff.diff?.removed ?? 0,37};38}3940function simpleDiff(path: string, added: number, removed: number): ISimpleDiff {41return { uri: URI.file(path).toString(), added, removed };42}4344suite('computeSessionDiffs', () => {4546ensureNoDisposablesAreLeakedInTestSuite();4748// ---- Full-mode tests (no incremental options) ---------------------------4950test('returns empty array for no edits', async () => {51const db = new TestSessionDatabase();52const diffService = createTestDiffService();53const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);54assert.deepStrictEqual(result, []);55});5657test('computes diffs for a single edited file', async () => {58const db = new TestSessionDatabase();59db.addEdit({60turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,61addedLines: undefined, removedLines: undefined,62beforeContent: encodeString('line1\nline2'), afterContent: encodeString('line1\nline2\nline3'),63});6465const diffService = createTestDiffService();66const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);6768assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);69assert.strictEqual(diffService.callCount, 1);70});7172test('populates before/after with session-db content URIs for edits', async () => {73const db = new TestSessionDatabase();74db.addEdit({75turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,76addedLines: undefined, removedLines: undefined,77beforeContent: encodeString('v1'), afterContent: encodeString('v2'),78});79db.addEdit({80turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,81addedLines: undefined, removedLines: undefined,82beforeContent: encodeString('v2'), afterContent: encodeString('v3'),83});8485const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService());8687assert.strictEqual(result.length, 1);88const [diff] = result;89const fileUri = URI.file('/a.txt').toString();90assert.strictEqual(diff.before?.uri, fileUri);91assert.strictEqual(diff.after?.uri, fileUri);9293// before content points to the FIRST snapshot (tc1)94const beforeFields = parseSessionDbUri(diff.before!.content.uri);95assert.deepStrictEqual(beforeFields, {96sessionUri: TEST_SESSION_URI,97toolCallId: 'tc1',98filePath: '/a.txt',99part: 'before',100});101102// after content points to the LAST snapshot (tc2)103const afterFields = parseSessionDbUri(diff.after!.content.uri);104assert.deepStrictEqual(afterFields, {105sessionUri: TEST_SESSION_URI,106toolCallId: 'tc2',107filePath: '/a.txt',108part: 'after',109});110});111112test('omits before for creates and after for deletes', async () => {113const db = new TestSessionDatabase();114db.addEdit({115turnId: 't1', toolCallId: 'tc1', filePath: '/created.txt', kind: FileEditKind.Create,116addedLines: undefined, removedLines: undefined,117afterContent: encodeString('new'),118});119db.addEdit({120turnId: 't1', toolCallId: 'tc2', filePath: '/deleted.txt', kind: FileEditKind.Delete,121addedLines: undefined, removedLines: undefined,122beforeContent: encodeString('bye'),123});124125const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService());126result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));127128assert.strictEqual(result.length, 2);129const [created, deleted] = result;130assert.strictEqual(created.before, undefined, 'create has no before');131assert.ok(created.after, 'create has after');132assert.ok(deleted.before, 'delete has before');133assert.strictEqual(deleted.after, undefined, 'delete has no after');134});135136test('skips files with no net change', async () => {137const db = new TestSessionDatabase();138db.addEdit({139turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,140addedLines: undefined, removedLines: undefined,141beforeContent: encodeString('same'), afterContent: encodeString('different'),142});143db.addEdit({144turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,145addedLines: undefined, removedLines: undefined,146beforeContent: encodeString('different'), afterContent: encodeString('same'),147});148149const diffService = createTestDiffService();150const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);151152// Before = tc1.before = 'same', After = tc2.after = 'same' → zero net change153assert.deepStrictEqual(result, []);154assert.strictEqual(diffService.callCount, 0, 'no diff computation needed for zero net change');155});156157test('tracks rename chains correctly', async () => {158const db = new TestSessionDatabase();159db.addEdit({160turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create,161addedLines: undefined, removedLines: undefined,162afterContent: encodeString('hello'),163});164db.addEdit({165turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename, originalPath: '/a.txt',166addedLines: undefined, removedLines: undefined,167beforeContent: encodeString('hello'), afterContent: encodeString('hello world'),168});169170const diffService = createTestDiffService();171const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);172173assert.strictEqual(result.length, 1);174assert.strictEqual(getDiffUri(result[0]), URI.file('/b.txt').toString(), 'uses terminal path after rename');175});176177// ---- Incremental-mode tests ---------------------------------------------178179test('incremental: reuses previousDiffs for untouched files', async () => {180const db = new TestSessionDatabase();181// File A edited in turn 1 only182db.addEdit({183turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,184addedLines: undefined, removedLines: undefined,185beforeContent: encodeString('a-before'), afterContent: encodeString('a-after'),186});187// File B edited in turn 2188db.addEdit({189turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Edit,190addedLines: undefined, removedLines: undefined,191beforeContent: encodeString('b-before'), afterContent: encodeString('b-after\nnew'),192});193194const previousDiffs: ISessionFileDiff[] = [195fileDiff('/a.txt', 42, 7),196];197198const diffService = createTestDiffService();199const result = await computeSessionDiffs(200TEST_SESSION_URI,201db,202diffService,203{ changedTurnId: 't2', previousDiffs },204);205206// Sort to ensure stable comparison207result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));208209assert.deepStrictEqual(result.map(simplify), [210simpleDiff('/a.txt', 42, 7), // carried over211simpleDiff('/b.txt', 1, 0), // recomputed212]);213// Only file B should have triggered a diff computation214assert.strictEqual(diffService.callCount, 1, 'only touched file should be diffed');215});216217test('incremental: recomputes file edited in current turn', async () => {218const db = new TestSessionDatabase();219// File A edited in turn 1 and turn 2220db.addEdit({221turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,222addedLines: undefined, removedLines: undefined,223beforeContent: encodeString('original'), afterContent: encodeString('after-turn1'),224});225db.addEdit({226turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,227addedLines: undefined, removedLines: undefined,228beforeContent: encodeString('after-turn1'), afterContent: encodeString('after-turn2\nextra'),229});230231const previousDiffs: ISessionFileDiff[] = [232fileDiff('/a.txt', 100, 100), // stale233];234235const diffService = createTestDiffService();236const result = await computeSessionDiffs(237TEST_SESSION_URI,238db,239diffService,240{ changedTurnId: 't2', previousDiffs },241);242243// Should compare tc1.before='original' vs tc2.after='after-turn2\nextra'244assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);245assert.strictEqual(diffService.callCount, 1);246});247248test('incremental: rename in current turn drops old URI from previousDiffs', async () => {249const db = new TestSessionDatabase();250// File created in turn 1251db.addEdit({252turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Create,253addedLines: undefined, removedLines: undefined,254afterContent: encodeString('content'),255});256// Renamed in turn 2257db.addEdit({258turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Rename,259originalPath: '/old.txt',260addedLines: undefined, removedLines: undefined,261beforeContent: encodeString('content'), afterContent: encodeString('content'),262});263264const previousDiffs: ISessionFileDiff[] = [265fileDiff('/old.txt', 5, 0),266];267268const diffService = createTestDiffService();269const result = await computeSessionDiffs(270TEST_SESSION_URI,271db,272diffService,273{ changedTurnId: 't2', previousDiffs },274);275276// Create → Rename with same content: before='' (create), after='content' (rename)277assert.strictEqual(result.length, 1);278assert.strictEqual(getDiffUri(result[0]), URI.file('/new.txt').toString(), 'uses new URI after rename');279});280281test('incremental: file with zero net change in current turn is excluded even if in previousDiffs', async () => {282const db = new TestSessionDatabase();283db.addEdit({284turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,285addedLines: undefined, removedLines: undefined,286beforeContent: encodeString('original'), afterContent: encodeString('modified'),287});288// Turn 2 reverts the change289db.addEdit({290turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,291addedLines: undefined, removedLines: undefined,292beforeContent: encodeString('modified'), afterContent: encodeString('original'),293});294295const previousDiffs: ISessionFileDiff[] = [296fileDiff('/a.txt', 10, 5),297];298299const diffService = createTestDiffService();300const result = await computeSessionDiffs(301TEST_SESSION_URI,302db,303diffService,304{ changedTurnId: 't2', previousDiffs },305);306307// Net change is zero (reverted), so file should be excluded308assert.deepStrictEqual(result, []);309});310311test('incremental: previousDiffs entry for file not in current identities is dropped (slow path)', async () => {312const db = new TestSessionDatabase();313// File A was edited in turn 1 and is in previousDiffs314db.addEdit({315turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,316addedLines: undefined, removedLines: undefined,317beforeContent: encodeString('before'), afterContent: encodeString('after'),318});319// File A is edited again in turn 2 → triggers slow path (re-edit of existing file)320db.addEdit({321turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,322addedLines: undefined, removedLines: undefined,323beforeContent: encodeString('after'), afterContent: encodeString('latest\nline'),324});325326const previousDiffs: ISessionFileDiff[] = [327fileDiff('/a.txt', 1, 0),328fileDiff('/orphan.txt', 99, 99), // no longer in DB329];330331const diffService = createTestDiffService();332const result = await computeSessionDiffs(333TEST_SESSION_URI,334db,335diffService,336{ changedTurnId: 't2', previousDiffs },337);338339// Slow path: orphan is dropped because it has no identity in the full graph340assert.strictEqual(result.length, 1);341assert.strictEqual(getDiffUri(result[0]), URI.file('/a.txt').toString());342});343344test('full mode recomputes all files (no incremental options)', async () => {345const db = new TestSessionDatabase();346db.addEdit({347turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,348addedLines: undefined, removedLines: undefined,349beforeContent: encodeString('a'), afterContent: encodeString('a\nb'),350});351db.addEdit({352turnId: 't1', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Create,353addedLines: undefined, removedLines: undefined,354afterContent: encodeString('new'),355});356357const diffService = createTestDiffService();358const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);359360assert.strictEqual(result.length, 2);361assert.strictEqual(diffService.callCount, 2, 'both files should be diffed in full mode');362});363364// ---- Fast-path tests (turn-scoped query optimization) -------------------365366test('incremental fast path: new files only uses getFileEditsByTurn, not getAllFileEdits', async () => {367const db = new TestSessionDatabase();368// Turn 1: existing file untouched in turn 2369db.addEdit({370turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Edit,371addedLines: undefined, removedLines: undefined,372beforeContent: encodeString('old-before'), afterContent: encodeString('old-after'),373});374// Turn 2: creates a new file375db.addEdit({376turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Create,377addedLines: undefined, removedLines: undefined,378afterContent: encodeString('brand new'),379});380381const previousDiffs: ISessionFileDiff[] = [382fileDiff('/old.txt', 3, 1),383];384385const diffService = createTestDiffService();386const result = await computeSessionDiffs(387TEST_SESSION_URI,388db,389diffService,390{ changedTurnId: 't2', previousDiffs },391);392393// Fast path: only getFileEditsByTurn called, not getAllFileEdits394assert.strictEqual(db.getFileEditsByTurnCalls, 1);395assert.strictEqual(db.getAllFileEditsCalls, 0, 'fast path should not call getAllFileEdits');396397result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));398assert.deepStrictEqual(result.map(simplify), [399simpleDiff('/new.txt', 1, 0),400simpleDiff('/old.txt', 3, 1), // carried over401]);402});403404test('incremental slow path: re-edit of existing file falls back to getAllFileEdits', async () => {405const db = new TestSessionDatabase();406// Turn 1: edit file A407db.addEdit({408turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,409addedLines: undefined, removedLines: undefined,410beforeContent: encodeString('original'), afterContent: encodeString('turn1'),411});412// Turn 2: edit file A again413db.addEdit({414turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,415addedLines: undefined, removedLines: undefined,416beforeContent: encodeString('turn1'), afterContent: encodeString('turn2\nextra'),417});418419const previousDiffs: ISessionFileDiff[] = [420fileDiff('/a.txt', 5, 0),421];422423const diffService = createTestDiffService();424const result = await computeSessionDiffs(425TEST_SESSION_URI,426db,427diffService,428{ changedTurnId: 't2', previousDiffs },429);430431// Slow path: falls back to getAllFileEdits because /a.txt is in previousDiffs432assert.strictEqual(db.getFileEditsByTurnCalls, 1, 'should try turn-scoped query first');433assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back to getAllFileEdits');434435// Cumulative diff: original → turn2\nextra436assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);437});438439test('incremental slow path: rename in current turn falls back to getAllFileEdits', async () => {440const db = new TestSessionDatabase();441db.addEdit({442turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create,443addedLines: undefined, removedLines: undefined,444afterContent: encodeString('content'),445});446db.addEdit({447turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename,448originalPath: '/a.txt',449addedLines: undefined, removedLines: undefined,450beforeContent: encodeString('content'), afterContent: encodeString('content'),451});452453const previousDiffs: ISessionFileDiff[] = [454fileDiff('/a.txt', 1, 0),455];456457const diffService = createTestDiffService();458await computeSessionDiffs(459TEST_SESSION_URI,460db,461diffService,462{ changedTurnId: 't2', previousDiffs },463);464465assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back for renames');466});467468test('incremental: no edits in turn returns previousDiffs unchanged', async () => {469const db = new TestSessionDatabase();470db.addEdit({471turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,472addedLines: undefined, removedLines: undefined,473beforeContent: encodeString('before'), afterContent: encodeString('after'),474});475476const previousDiffs: ISessionFileDiff[] = [477fileDiff('/a.txt', 5, 2),478];479480const diffService = createTestDiffService();481const result = await computeSessionDiffs(482TEST_SESSION_URI,483db,484diffService,485{ changedTurnId: 't2', previousDiffs },486);487488assert.strictEqual(db.getAllFileEditsCalls, 0, 'no computation needed');489assert.deepStrictEqual(result, previousDiffs);490});491});492493494