mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-27 03:54:24 +01:00
agentHost: data passing for edits
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
123
src/vs/platform/agentHost/test/node/fileEditTracker.test.ts
Normal file
123
src/vs/platform/agentHost/test/node/fileEditTracker.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 ----------------------------------------------------------------
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user