Files
vscode/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts
T
Don JayamanneandCopilot f321c7605d agentHost: multi-root turn changeset diffs (foundation) (#329531)
* agentHost: multi-root turn changeset diffs (foundation)

Compute per-turn and static (branch/session/uncommitted) changesets across all
working directories of a multi-root agent host session: partition git
repositories vs non-git folders, dedupe repositories shared by multiple folders,
cap per-turn fan-out, and fall back to tracked edits when a git diff is
unavailable. Single-folder sessions keep their existing behavior.

Telemetry:
- New `agentHost.changesetComputed` event (branch/session/uncommitted/turn
  kinds) reporting compute duration, outcome, and the resolved multi-root git
  topology; static and per-turn reporters funnel into the one event.
- Add `isMultiRoot`/`folderCount` (and browser-projected git topology) to
  `agentHost.turnCompleted`, `agents/requestSent`, and `agents/sessionSummary`.
- Bound the `resolveSessionRepositories` git rev-parse fan-out with a
  concurrency limiter.

Also includes documentation clarifications and tracked-edit helper renames.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* agentHost: Implement robust worktree removal with retry logic for transient errors

* agentHost: replace 20-repo changeset cap with bounded-concurrency fan-out

Per-turn and branch-summary changeset diffs previously capped to the first
20 git repositories (slice(0, 20) + unbounded Promise.all), silently
dropping repositories 21+ and warning about "capping". Diff every resolved
repository through a per-call Limiter(5) fan-out instead, mirroring the
built-in git extension, so no repository is dropped while at most 5 git
processes run concurrently.

- Rename MAX_TURN_DIFF_REPOSITORIES (20) to MAX_TURN_DIFF_REPOSITORY_CONCURRENCY (5)
- Lower REPOSITORY_ROOT_RESOLUTION_CONCURRENCY from 8 to 5
- Remove the now-dead capHit / diffedGitFolderCount telemetry
- Rewrite the cap test as a bounded-concurrency + no-truncation regression

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-10 11:06:17 +00:00

831 lines
32 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js';
import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js';
import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs } from '../../node/sessionDiffAggregator.js';
import { parseSessionDbUri } from '../../common/sessionDbUri.js';
const TEST_SESSION_URI = 'session://test-session';
const createTestDiffService = () => new TestDiffComputeService();
function fileDiff(path: string, added: number, removed: number): ISessionFileDiff {
const uri = URI.file(path).toString();
return { after: { uri, content: { uri } }, diff: { added, removed } };
}
function getDiffUri(diff: ISessionFileDiff): string | undefined {
return diff.after?.uri ?? diff.before?.uri;
}
interface ISimpleDiff {
uri: string | undefined;
added: number;
removed: number;
}
function simplify(diff: ISessionFileDiff): ISimpleDiff {
return {
uri: getDiffUri(diff),
added: diff.diff?.added ?? 0,
removed: diff.diff?.removed ?? 0,
};
}
function simpleDiff(path: string, added: number, removed: number): ISimpleDiff {
return { uri: URI.file(path).toString(), added, removed };
}
suite('computeSessionDiffs', () => {
ensureNoDisposablesAreLeakedInTestSuite();
// ---- Full-mode tests (no incremental options) ---------------------------
test('returns empty array for no edits', async () => {
const db = new TestSessionDatabase();
const diffService = createTestDiffService();
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
assert.deepStrictEqual(result, []);
});
test('computes diffs for a single edited file', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('line1\nline2'), afterContent: encodeString('line1\nline2\nline3'),
});
const diffService = createTestDiffService();
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);
assert.strictEqual(diffService.callCount, 1);
});
test('populates before/after with session-db content URIs for edits', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('v1'), afterContent: encodeString('v2'),
});
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('v2'), afterContent: encodeString('v3'),
});
const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService());
assert.strictEqual(result.length, 1);
const [diff] = result;
const fileUri = URI.file('/a.txt').toString();
assert.strictEqual(diff.before?.uri, fileUri);
assert.strictEqual(diff.after?.uri, fileUri);
// before content points to the FIRST snapshot (tc1)
const beforeFields = parseSessionDbUri(diff.before!.content.uri);
assert.deepStrictEqual(beforeFields, {
sessionUri: TEST_SESSION_URI,
toolCallId: 'tc1',
filePath: '/a.txt',
part: 'before',
});
// after content points to the LAST snapshot (tc2)
const afterFields = parseSessionDbUri(diff.after!.content.uri);
assert.deepStrictEqual(afterFields, {
sessionUri: TEST_SESSION_URI,
toolCallId: 'tc2',
filePath: '/a.txt',
part: 'after',
});
});
test('omits before for creates and after for deletes', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/created.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('new'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/deleted.txt', kind: FileEditKind.Delete,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('bye'),
});
const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService());
result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));
assert.strictEqual(result.length, 2);
const [created, deleted] = result;
assert.strictEqual(created.before, undefined, 'create has no before');
assert.ok(created.after, 'create has after');
assert.ok(deleted.before, 'delete has before');
assert.strictEqual(deleted.after, undefined, 'delete has no after');
});
test('skips files with no net change', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('same'), afterContent: encodeString('different'),
});
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('different'), afterContent: encodeString('same'),
});
const diffService = createTestDiffService();
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
// Before = tc1.before = 'same', After = tc2.after = 'same' → zero net change
assert.deepStrictEqual(result, []);
assert.strictEqual(diffService.callCount, 0, 'no diff computation needed for zero net change');
});
test('tracks rename chains correctly', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('hello'),
});
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename, originalPath: '/a.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('hello'), afterContent: encodeString('hello world'),
});
const diffService = createTestDiffService();
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
assert.strictEqual(result.length, 1);
assert.strictEqual(getDiffUri(result[0]), URI.file('/b.txt').toString(), 'uses terminal path after rename');
});
// ---- Incremental-mode tests ---------------------------------------------
test('incremental: reuses previousDiffs for untouched files', async () => {
const db = new TestSessionDatabase();
// File A edited in turn 1 only
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a-before'), afterContent: encodeString('a-after'),
});
// File B edited in turn 2
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('b-before'), afterContent: encodeString('b-after\nnew'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 42, 7),
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Sort to ensure stable comparison
result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));
assert.deepStrictEqual(result.map(simplify), [
simpleDiff('/a.txt', 42, 7), // carried over
simpleDiff('/b.txt', 1, 0), // recomputed
]);
// Only file B should have triggered a diff computation
assert.strictEqual(diffService.callCount, 1, 'only touched file should be diffed');
});
test('incremental: recomputes file edited in current turn', async () => {
const db = new TestSessionDatabase();
// File A edited in turn 1 and turn 2
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('original'), afterContent: encodeString('after-turn1'),
});
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('after-turn1'), afterContent: encodeString('after-turn2\nextra'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 100, 100), // stale
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Should compare tc1.before='original' vs tc2.after='after-turn2\nextra'
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);
assert.strictEqual(diffService.callCount, 1);
});
test('incremental: rename in current turn drops old URI from previousDiffs', async () => {
const db = new TestSessionDatabase();
// File created in turn 1
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('content'),
});
// Renamed in turn 2
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Rename,
originalPath: '/old.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('content'), afterContent: encodeString('content'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/old.txt', 5, 0),
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Create → Rename with same content: before='' (create), after='content' (rename)
assert.strictEqual(result.length, 1);
assert.strictEqual(getDiffUri(result[0]), URI.file('/new.txt').toString(), 'uses new URI after rename');
});
test('incremental: file with zero net change in current turn is excluded even if in previousDiffs', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('original'), afterContent: encodeString('modified'),
});
// Turn 2 reverts the change
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('modified'), afterContent: encodeString('original'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 10, 5),
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Net change is zero (reverted), so file should be excluded
assert.deepStrictEqual(result, []);
});
test('incremental: previousDiffs entry for file not in current identities is dropped (slow path)', async () => {
const db = new TestSessionDatabase();
// File A was edited in turn 1 and is in previousDiffs
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('before'), afterContent: encodeString('after'),
});
// File A is edited again in turn 2 → triggers slow path (re-edit of existing file)
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('after'), afterContent: encodeString('latest\nline'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 1, 0),
fileDiff('/orphan.txt', 99, 99), // no longer in DB
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Slow path: orphan is dropped because it has no identity in the full graph
assert.strictEqual(result.length, 1);
assert.strictEqual(getDiffUri(result[0]), URI.file('/a.txt').toString());
});
test('full mode recomputes all files (no incremental options)', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a'), afterContent: encodeString('a\nb'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('new'),
});
const diffService = createTestDiffService();
const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService);
assert.strictEqual(result.length, 2);
assert.strictEqual(diffService.callCount, 2, 'both files should be diffed in full mode');
});
// ---- Fast-path tests (turn-scoped query optimization) -------------------
test('incremental fast path: new files only uses getFileEditsByTurn, not getAllFileEdits', async () => {
const db = new TestSessionDatabase();
// Turn 1: existing file untouched in turn 2
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/old.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('old-before'), afterContent: encodeString('old-after'),
});
// Turn 2: creates a new file
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/new.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('brand new'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/old.txt', 3, 1),
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Fast path: only getFileEditsByTurn called, not getAllFileEdits
assert.strictEqual(db.getFileEditsByTurnCalls, 1);
assert.strictEqual(db.getAllFileEditsCalls, 0, 'fast path should not call getAllFileEdits');
result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? ''));
assert.deepStrictEqual(result.map(simplify), [
simpleDiff('/new.txt', 1, 0),
simpleDiff('/old.txt', 3, 1), // carried over
]);
});
test('incremental slow path: re-edit of existing file falls back to getAllFileEdits', async () => {
const db = new TestSessionDatabase();
// Turn 1: edit file A
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('original'), afterContent: encodeString('turn1'),
});
// Turn 2: edit file A again
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('turn1'), afterContent: encodeString('turn2\nextra'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 5, 0),
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
// Slow path: falls back to getAllFileEdits because /a.txt is in previousDiffs
assert.strictEqual(db.getFileEditsByTurnCalls, 1, 'should try turn-scoped query first');
assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back to getAllFileEdits');
// Cumulative diff: original → turn2\nextra
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]);
});
test('incremental slow path: rename in current turn falls back to getAllFileEdits', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('content'),
});
db.addEdit({
turnId: 't2', toolCallId: 'tc2', filePath: '/b.txt', kind: FileEditKind.Rename,
originalPath: '/a.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('content'), afterContent: encodeString('content'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 1, 0),
];
const diffService = createTestDiffService();
await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back for renames');
});
test('incremental: no edits in turn returns previousDiffs unchanged', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('before'), afterContent: encodeString('after'),
});
const previousDiffs: ISessionFileDiff[] = [
fileDiff('/a.txt', 5, 2),
];
const diffService = createTestDiffService();
const result = await computeSessionDiffs(
TEST_SESSION_URI,
db,
diffService,
{ changedTurnId: 't2', previousDiffs },
);
assert.strictEqual(db.getAllFileEditsCalls, 0, 'no computation needed');
assert.deepStrictEqual(result, previousDiffs);
});
test('throws when a folderScope is combined with incremental mode (unsupported combination)', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('before'), afterContent: encodeString('after'),
});
await assert.rejects(
() => computeSessionDiffs(
TEST_SESSION_URI,
db,
createTestDiffService(),
{ changedTurnId: 't1', previousDiffs: [] },
[URI.file('/a')],
),
/folderScope` is not supported in incremental mode/,
);
});
});
suite('computeUnionedDiffs', () => {
ensureNoDisposablesAreLeakedInTestSuite();
const PEER_CHAT_URI = 'ahp-chat://peer/encoded';
test('returns empty array when no source has edits', async () => {
const result = await computeUnionedDiffs(
[{ sessionUri: TEST_SESSION_URI, db: new TestSessionDatabase() }],
createTestDiffService(),
);
assert.deepStrictEqual(result, []);
});
test('unions edits from the session DB and a peer chat DB', async () => {
const sessionDb = new TestSessionDatabase();
sessionDb.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a1'), afterContent: encodeString('a1\na2'),
});
const peerDb = new TestSessionDatabase();
peerDb.addEdit({
turnId: 'pt1', toolCallId: 'ptc1', filePath: '/b.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
beforeContent: undefined, afterContent: encodeString('b1\nb2\nb3'),
});
const result = await computeUnionedDiffs(
[
{ sessionUri: TEST_SESSION_URI, db: sessionDb },
{ sessionUri: PEER_CHAT_URI, db: peerDb },
],
createTestDiffService(),
);
assert.deepStrictEqual(
result.map(simplify).sort((x, y) => (x.uri ?? '').localeCompare(y.uri ?? '')),
[simpleDiff('/a.txt', 1, 0), simpleDiff('/b.txt', 3, 0)],
);
// The peer file's content URI must encode the peer chat URI so the
// resource resolver opens the peer DB, not the session DB.
const peerDiff = result.find(d => getDiffUri(d) === URI.file('/b.txt').toString())!;
const afterFields = parseSessionDbUri(peerDiff.after!.content.uri);
assert.deepStrictEqual(afterFields, {
sessionUri: PEER_CHAT_URI,
toolCallId: 'ptc1',
filePath: '/b.txt',
part: 'after',
});
});
test('a file edited by multiple sources takes before from the first and after from the last source', async () => {
const sessionDb = new TestSessionDatabase();
sessionDb.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/shared.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('v1'), afterContent: encodeString('v2'),
});
const peerDb = new TestSessionDatabase();
peerDb.addEdit({
turnId: 'pt1', toolCallId: 'ptc1', filePath: '/shared.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('v2'), afterContent: encodeString('v3'),
});
const result = await computeUnionedDiffs(
[
{ sessionUri: TEST_SESSION_URI, db: sessionDb },
{ sessionUri: PEER_CHAT_URI, db: peerDb },
],
createTestDiffService(),
);
assert.strictEqual(result.length, 1);
const [diff] = result;
// before snapshot from the session DB (first source)
assert.deepStrictEqual(parseSessionDbUri(diff.before!.content.uri), {
sessionUri: TEST_SESSION_URI,
toolCallId: 'tc1',
filePath: '/shared.txt',
part: 'before',
});
// after snapshot from the peer chat DB (last source)
assert.deepStrictEqual(parseSessionDbUri(diff.after!.content.uri), {
sessionUri: PEER_CHAT_URI,
toolCallId: 'ptc1',
filePath: '/shared.txt',
part: 'after',
});
});
});
suite('computeTurnDiffs', () => {
ensureNoDisposablesAreLeakedInTestSuite();
// ---- No folder scope (characterization — same behavior as today) --------
test('no folderScope returns all of the turn\'s edits', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a/x.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('1'), afterContent: encodeString('1\n2'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/repo/b/y.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('new'),
});
// An edit in a different turn must never contribute.
db.addEdit({
turnId: 't2', toolCallId: 'tc3', filePath: '/repo/a/z.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a'), afterContent: encodeString('a\nb'),
});
const result = await computeTurnDiffs(TEST_SESSION_URI, db, createTestDiffService(), 't1');
assert.deepStrictEqual(
result.map(simplify).sort((x, y) => (x.uri ?? '').localeCompare(y.uri ?? '')),
[simpleDiff('/repo/a/x.txt', 1, 0), simpleDiff('/repo/b/y.txt', 1, 0)],
);
});
// ---- Folder-scope filtering --------------------------------------------
test('folderScope [A] includes only edits under A (B excluded)', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a/x.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('1'), afterContent: encodeString('1\n2'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/repo/b/y.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('b'), afterContent: encodeString('b\nc'),
});
const result = await computeTurnDiffs(
TEST_SESSION_URI, db, createTestDiffService(), 't1', [URI.file('/repo/a')],
);
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/repo/a/x.txt', 1, 0)]);
});
test('folderScope: rename and delete within scope are kept; edits/renames out of scope are excluded', async () => {
const db = new TestSessionDatabase();
// Rename within scope A: judged by after-path (kept).
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a/new.txt', kind: FileEditKind.Rename,
originalPath: '/repo/a/old.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('hello'), afterContent: encodeString('hello\nworld'),
});
// Delete within scope A (kept).
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/repo/a/gone.txt', kind: FileEditKind.Delete,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('bye'),
});
// Plain edit outside scope (excluded).
db.addEdit({
turnId: 't1', toolCallId: 'tc3', filePath: '/repo/b/keep.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('k'), afterContent: encodeString('k\nk2'),
});
// Rename that moves a file OUT of scope: after-path is under B (excluded).
db.addEdit({
turnId: 't1', toolCallId: 'tc4', filePath: '/repo/b/moved.txt', kind: FileEditKind.Rename,
originalPath: '/repo/a/moving.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('m'), afterContent: encodeString('m\nm2'),
});
const result = await computeTurnDiffs(
TEST_SESSION_URI, db, createTestDiffService(), 't1', [URI.file('/repo/a')],
);
// Filtering semantics: only the in-scope rename (reported at its terminal
// path /repo/a/new.txt) and the in-scope delete (/repo/a/gone.txt) survive;
// the out-of-scope edit and the rename that moves a file out of scope are
// dropped. Assert on the reported URIs rather than exact diff counts so the
// test targets the scope filter, not the fake diff service's line math.
const uris = new Set(result.map(getDiffUri));
assert.strictEqual(result.length, 2, 'exactly the two in-scope files are reported');
assert.ok(uris.has(URI.file('/repo/a/new.txt').toString()), 'in-scope rename kept at terminal path');
assert.ok(uris.has(URI.file('/repo/a/gone.txt').toString()), 'in-scope delete kept');
assert.ok(!uris.has(URI.file('/repo/b/keep.txt').toString()), 'out-of-scope edit excluded');
assert.ok(!uris.has(URI.file('/repo/b/moved.txt').toString()), 'rename moving a file out of scope excluded');
// The rename result reports its terminal (in-scope) path with before/after.
const rename = result.find(d => getDiffUri(d) === URI.file('/repo/a/new.txt').toString())!;
assert.ok(rename.before, 'rename keeps a before snapshot');
assert.ok(rename.after, 'rename keeps an after snapshot');
// The delete result reports its before path and has no after.
const del = result.find(d => getDiffUri(d) === URI.file('/repo/a/gone.txt').toString())!;
assert.ok(del.before, 'delete has a before');
assert.strictEqual(del.after, undefined, 'delete has no after');
});
test('folderScope: rename chains are followed before scoping — in-scope edit then rename OUT of scope reports nothing (no stale path)', async () => {
const db = new TestSessionDatabase();
// Same logical file: edited while in scope A, then renamed out to B.
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a/x.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('1'), afterContent: encodeString('1\n2'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/repo/b/x.txt', kind: FileEditKind.Rename,
originalPath: '/repo/a/x.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('1\n2'), afterContent: encodeString('1\n2\n3'),
});
const result = await computeTurnDiffs(
TEST_SESSION_URI, db, createTestDiffService(), 't1', [URI.file('/repo/a')],
);
// The identity's final path is /repo/b/x.txt (out of scope), so the file
// is dropped entirely. Pre-filtering raw records (the previous bug) would
// have kept the in-scope edit record and reported a stale /repo/a/x.txt.
assert.deepStrictEqual(result.map(simplify), []);
});
test('folderScope: rename chains are followed before scoping — edit OUT of scope then rename INTO scope keeps the full before/after chain', async () => {
const db = new TestSessionDatabase();
// Same logical file: edited while out of scope (B), then renamed into A.
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/b/y.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('1'), afterContent: encodeString('1\n2'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/repo/a/y.txt', kind: FileEditKind.Rename,
originalPath: '/repo/b/y.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('1\n2'), afterContent: encodeString('1\n2\n3'),
});
const result = await computeTurnDiffs(
TEST_SESSION_URI, db, createTestDiffService(), 't1', [URI.file('/repo/a')],
);
// Reported at its in-scope terminal path /repo/a/y.txt with `before` taken
// from the pre-rename edit (content '1', one line) -> `added` is 2. Pre-
// filtering (the previous bug) dropped the out-of-scope edit, losing the
// before snapshot (empty), which would have reported `added` 3.
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/repo/a/y.txt', 2, 0)]);
});
test('folderScope: a file path exactly at a folder root is kept', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a/x.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a'), afterContent: encodeString('a\nb'),
});
const result = await computeTurnDiffs(
TEST_SESSION_URI, db, createTestDiffService(), 't1', [URI.file('/repo/a/x.txt')],
);
assert.deepStrictEqual(result.map(simplify), [simpleDiff('/repo/a/x.txt', 1, 0)]);
});
test('folderScope: an empty scope excludes every edit', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a/x.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a'), afterContent: encodeString('a\nb'),
});
const result = await computeTurnDiffs(
TEST_SESSION_URI, db, createTestDiffService(), 't1', [],
);
assert.deepStrictEqual(result, []);
});
// ---- Rename-chain correctness (G1) -------------------------------------
test('G1: Edit A → Rename A→B → Create A yields two identities (moved B and new A), not one merged entry', async () => {
const db = new TestSessionDatabase();
db.addEdit({
turnId: 't1', toolCallId: 'tc1', filePath: '/repo/a.txt', kind: FileEditKind.Edit,
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a1'), afterContent: encodeString('a1\na2'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc2', filePath: '/repo/b.txt', kind: FileEditKind.Rename,
originalPath: '/repo/a.txt',
addedLines: undefined, removedLines: undefined,
beforeContent: encodeString('a1\na2'), afterContent: encodeString('a1\na2\na3'),
});
db.addEdit({
turnId: 't1', toolCallId: 'tc3', filePath: '/repo/a.txt', kind: FileEditKind.Create,
addedLines: undefined, removedLines: undefined,
afterContent: encodeString('brand new a'),
});
const result = await computeTurnDiffs(TEST_SESSION_URI, db, createTestDiffService(), 't1');
const uris = new Set(result.map(getDiffUri));
assert.strictEqual(result.length, 2, 'the moved file (B) and the recreated file (A) are distinct identities');
assert.ok(uris.has(URI.file('/repo/b.txt').toString()), 'the renamed file appears at its destination path B');
assert.ok(uris.has(URI.file('/repo/a.txt').toString()), 'the recreated file A is a fresh identity');
});
});