agentHost: data passing for edits

This commit is contained in:
Connor Peet
2026-03-22 11:28:38 -07:00
parent 30a711eccb
commit 2bdd783838
28 changed files with 968 additions and 75 deletions

View File

@@ -31,7 +31,7 @@ import type {
ITurnCompleteAction,
IUsageAction,
} from '../../common/state/sessionActions.js';
import { PermissionKind } from '../../common/state/sessionState.js';
import { PermissionKind, ToolResultContentType } from '../../common/state/sessionState.js';
import { mapProgressEventToActions } from '../../node/agentEventMapper.js';
/** Helper: flatten the result of mapProgressEventToActions into an array. */
@@ -104,9 +104,11 @@ suite('AgentEventMapper', () => {
session,
type: 'tool_complete',
toolCallId: 'tc-1',
success: true,
pastTenseMessage: 'Read file successfully',
toolOutput: 'file contents here',
result: {
success: true,
pastTenseMessage: 'Read file successfully',
content: [{ type: ToolResultContentType.Text, text: 'file contents here' }],
},
};
const actions = mapToArray(mapProgressEventToActions(event, session.toString(), turnId));

View File

@@ -10,6 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
import { NullLogService } from '../../../log/common/log.js';
import { FileService } from '../../../files/common/fileService.js';
import { AgentSession } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js';
import { AgentService } from '../../node/agentService.js';
import { MockAgent } from './mockAgent.js';
@@ -21,7 +22,14 @@ suite('AgentService (node dispatcher)', () => {
let copilotAgent: MockAgent;
setup(() => {
service = disposables.add(new AgentService(new NullLogService(), disposables.add(new FileService(new NullLogService()))));
const nullSessionDataService: ISessionDataService = {
_serviceBrand: undefined,
getSessionDataDir: () => URI.parse('inmemory:/session-data'),
getSessionDataDirById: () => URI.parse('inmemory:/session-data'),
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
};
service = disposables.add(new AgentService(new NullLogService(), disposables.add(new FileService(new NullLogService())), nullSessionDataService));
copilotAgent = new MockAgent('copilot');
disposables.add(toDisposable(() => copilotAgent.dispose()));
});

View File

@@ -14,6 +14,7 @@ import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { AgentSession, IAgent } from '../../common/agentService.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js';
import { PermissionKind, SessionStatus } from '../../common/state/sessionState.js';
import { AgentSideEffects } from '../../node/agentSideEffects.js';
@@ -69,6 +70,13 @@ suite('AgentSideEffects', () => {
sideEffects = disposables.add(new AgentSideEffects(stateManager, {
getAgent: () => agent,
agents: agentList,
sessionDataService: {
_serviceBrand: undefined,
getSessionDataDir: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
getSessionDataDirById: () => URI.from({ scheme: Schemas.inMemory, path: '/session-data' }),
deleteSessionData: async () => { },
cleanupOrphanedData: async () => { },
} satisfies ISessionDataService,
}, new NullLogService(), fileService));
});

View File

@@ -0,0 +1,123 @@
/*---------------------------------------------------------------------------------------------
* 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 { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { ISessionDataService } from '../../common/sessionDataService.js';
import { ToolResultContentType } from '../../common/state/sessionState.js';
import { SessionDataService } from '../../node/sessionDataService.js';
import { AGENT_CONTENT_SCHEME, FileEditTracker } from '../../node/copilot/fileEditTracker.js';
suite('FileEditTracker', () => {
const disposables = new DisposableStore();
let fileService: FileService;
let sessionDataService: ISessionDataService;
let tracker: FileEditTracker;
const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' });
setup(() => {
fileService = disposables.add(new FileService(new NullLogService()));
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
sessionDataService = new SessionDataService(basePath, fileService, new NullLogService());
tracker = new FileEditTracker('test-session', sessionDataService, fileService, new NullLogService());
});
teardown(() => disposables.clear());
ensureNoDisposablesAreLeakedInTestSuite();
test('tracks edit start and complete for existing file', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('original content\nline 2'));
await tracker.trackEditStart('/workspace/test.txt');
await fileService.writeFile(URI.file('/workspace/test.txt'), VSBuffer.fromString('modified content\nline 2\nline 3'));
await tracker.completeEdit('/workspace/test.txt');
const fileEdit = tracker.takeCompletedEdit('/workspace/test.txt');
assert.ok(fileEdit);
assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit);
assert.strictEqual(fileEdit.diff?.added, 1);
assert.strictEqual(fileEdit.diff?.removed, 0);
});
test('tracks edit for newly created file (no before content)', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
await tracker.trackEditStart('/workspace/new-file.txt');
await fileService.writeFile(URI.file('/workspace/new-file.txt'), VSBuffer.fromString('new file\ncontent'));
await tracker.completeEdit('/workspace/new-file.txt');
const fileEdit = tracker.takeCompletedEdit('/workspace/new-file.txt');
assert.ok(fileEdit);
assert.strictEqual(fileEdit.diff?.added, 2);
assert.strictEqual(fileEdit.diff?.removed, 0);
});
test('takeCompletedEdit returns undefined for unknown file path', () => {
const result = tracker.takeCompletedEdit('/nonexistent');
assert.strictEqual(result, undefined);
});
test('content URIs use the correct scheme and format', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('code'));
await tracker.trackEditStart('/workspace/file.ts');
await fileService.writeFile(URI.file('/workspace/file.ts'), VSBuffer.fromString('new code'));
await tracker.completeEdit('/workspace/file.ts');
const fileEdit = tracker.takeCompletedEdit('/workspace/file.ts');
assert.ok(fileEdit);
assert.ok(fileEdit.beforeURI.startsWith(`${AGENT_CONTENT_SCHEME}:///test-session/file-edits/`));
assert.ok(fileEdit.afterURI.startsWith(`${AGENT_CONTENT_SCHEME}:///test-session/file-edits/`));
assert.ok(fileEdit.beforeURI.endsWith('/before'));
assert.ok(fileEdit.afterURI.endsWith('/after'));
});
test('resolveContentUri maps to session data directory', async () => {
const sourceFs = disposables.add(new InMemoryFileSystemProvider());
disposables.add(fileService.registerProvider(Schemas.file, sourceFs));
await fileService.writeFile(URI.file('/workspace/resolve.txt'), VSBuffer.fromString('x'));
await tracker.trackEditStart('/workspace/resolve.txt');
await tracker.completeEdit('/workspace/resolve.txt');
const fileEdit = tracker.takeCompletedEdit('/workspace/resolve.txt');
assert.ok(fileEdit);
const resolved = FileEditTracker.resolveContentUri(fileEdit.beforeURI, sessionDataService);
assert.ok(resolved);
const sessionDir = sessionDataService.getSessionDataDirById('test-session');
assert.ok(resolved.toString().startsWith(sessionDir.toString()));
});
test('resolveContentUri returns undefined for unknown scheme', () => {
const resolved = FileEditTracker.resolveContentUri(
'https:///test-session/file-edits/tc-1/before',
sessionDataService,
);
assert.strictEqual(resolved, undefined);
});
test('resolveContentUri returns undefined for invalid path', () => {
const resolved = FileEditTracker.resolveContentUri(
`${AGENT_CONTENT_SCHEME}:///test-session/invalid/path`,
sessionDataService,
);
assert.strictEqual(resolved, undefined);
});
});

View File

@@ -92,6 +92,9 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler {
getDefaultDirectory(): string {
return URI.file('/home/testuser').toString();
}
async handleFetchContent(_uri: string): Promise<{ data: string; encoding: 'utf-8'; contentType?: string }> {
throw new Error('Not implemented');
}
}
// ---- Helpers ----------------------------------------------------------------

View File

@@ -0,0 +1,82 @@
/*---------------------------------------------------------------------------------------------
* 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 { VSBuffer } from '../../../../base/common/buffer.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../base/common/network.js';
import { URI } from '../../../../base/common/uri.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { FileService } from '../../../files/common/fileService.js';
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
import { NullLogService } from '../../../log/common/log.js';
import { AgentSession } from '../../common/agentService.js';
import { SessionDataService } from '../../node/sessionDataService.js';
suite('SessionDataService', () => {
const disposables = new DisposableStore();
let fileService: FileService;
let service: SessionDataService;
const basePath = URI.from({ scheme: Schemas.inMemory, path: '/userData' });
setup(() => {
fileService = disposables.add(new FileService(new NullLogService()));
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
service = new SessionDataService(basePath, fileService, new NullLogService());
});
teardown(() => disposables.clear());
ensureNoDisposablesAreLeakedInTestSuite();
test('getSessionDataDir returns correct URI', () => {
const session = AgentSession.uri('copilot', 'abc-123');
const dir = service.getSessionDataDir(session);
assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'abc-123').toString());
});
test('getSessionDataDir sanitizes unsafe characters', () => {
const session = AgentSession.uri('copilot', 'foo/bar:baz\\qux');
const dir = service.getSessionDataDir(session);
assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'foo-bar-baz-qux').toString());
});
test('deleteSessionData removes directory', async () => {
const session = AgentSession.uri('copilot', 'session-1');
const dir = service.getSessionDataDir(session);
await fileService.createFolder(dir);
await fileService.writeFile(URI.joinPath(dir, 'snapshot.json'), VSBuffer.fromString('{}'));
assert.ok(await fileService.exists(dir));
await service.deleteSessionData(session);
assert.ok(!(await fileService.exists(dir)));
});
test('deleteSessionData is a no-op when directory does not exist', async () => {
const session = AgentSession.uri('copilot', 'nonexistent');
// Should not throw
await service.deleteSessionData(session);
});
test('cleanupOrphanedData deletes orphans but keeps known sessions', async () => {
const baseDir = URI.joinPath(basePath, 'agentSessionData');
await fileService.createFolder(URI.joinPath(baseDir, 'keep-1'));
await fileService.createFolder(URI.joinPath(baseDir, 'keep-2'));
await fileService.createFolder(URI.joinPath(baseDir, 'orphan-1'));
await fileService.createFolder(URI.joinPath(baseDir, 'orphan-2'));
await service.cleanupOrphanedData(new Set(['keep-1', 'keep-2']));
assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-1')));
assert.ok(await fileService.exists(URI.joinPath(baseDir, 'keep-2')));
assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-1'))));
assert.ok(!(await fileService.exists(URI.joinPath(baseDir, 'orphan-2'))));
});
test('cleanupOrphanedData is a no-op when base directory does not exist', async () => {
// Should not throw
await service.cleanupOrphanedData(new Set());
});
});