mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-09 10:42:28 +01:00
* 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>
831 lines
32 KiB
TypeScript
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');
|
|
});
|
|
});
|