mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-29 04:53:33 +01:00
Refactor CopilotAgent, break out CopilotAgentSession, add tests (#306046)
* Refactor CopilotAgent, break out CopilotAgentSession, add tests Co-authored-by: Copilot <copilot@github.com> * Cleanup a bit Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
336
src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
Normal file
336
src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk';
|
||||
import { Emitter } from '../../../../base/common/event.js';
|
||||
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { NullLogService, ILogService } from '../../../log/common/log.js';
|
||||
import { IFileService } from '../../../files/common/files.js';
|
||||
import { AgentSession, IAgentProgressEvent } from '../../common/agentService.js';
|
||||
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { CopilotAgentSession, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js';
|
||||
import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js';
|
||||
import { InstantiationService } from '../../../instantiation/common/instantiationService.js';
|
||||
import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js';
|
||||
|
||||
// ---- Mock CopilotSession (SDK level) ----------------------------------------
|
||||
|
||||
/**
|
||||
* Minimal mock of the SDK's {@link CopilotSession}. Implements `on()` to
|
||||
* store typed handlers, and exposes `fire()` so tests can push events
|
||||
* through the real {@link CopilotSessionWrapper} event pipeline.
|
||||
*/
|
||||
class MockCopilotSession {
|
||||
readonly sessionId = 'test-session-1';
|
||||
|
||||
private readonly _handlers = new Map<string, Set<(event: SessionEvent) => void>>();
|
||||
|
||||
on<K extends SessionEventType>(eventType: K, handler: TypedSessionEventHandler<K>): () => void {
|
||||
let set = this._handlers.get(eventType);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this._handlers.set(eventType, set);
|
||||
}
|
||||
set.add(handler as (event: SessionEvent) => void);
|
||||
return () => { set.delete(handler as (event: SessionEvent) => void); };
|
||||
}
|
||||
|
||||
/** Push an event through to all registered handlers of the given type. */
|
||||
fire<K extends SessionEventType>(type: K, data: SessionEventPayload<K>['data']): void {
|
||||
const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null } as SessionEventPayload<K>;
|
||||
const set = this._handlers.get(type);
|
||||
if (set) {
|
||||
for (const handler of set) {
|
||||
handler(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stubs for methods the wrapper / session class calls
|
||||
async send() { return ''; }
|
||||
async abort() { }
|
||||
async setModel() { }
|
||||
async getMessages() { return []; }
|
||||
async destroy() { }
|
||||
}
|
||||
|
||||
// ---- Helpers ----------------------------------------------------------------
|
||||
|
||||
function createMockSessionDataService(): ISessionDataService {
|
||||
const mockDb: ISessionDatabase = {
|
||||
createTurn: async () => { },
|
||||
deleteTurn: async () => { },
|
||||
storeFileEdit: async () => { },
|
||||
getFileEdits: async () => [],
|
||||
readFileEditContent: async () => undefined,
|
||||
close: async () => { },
|
||||
dispose: () => { },
|
||||
};
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
getSessionDataDir: () => URI.from({ scheme: 'test', path: '/data' }),
|
||||
getSessionDataDirById: () => URI.from({ scheme: 'test', path: '/data' }),
|
||||
openDatabase: () => ({ object: mockDb, dispose: () => { } }),
|
||||
deleteSessionData: async () => { },
|
||||
cleanupOrphanedData: async () => { },
|
||||
};
|
||||
}
|
||||
|
||||
async function createAgentSession(disposables: DisposableStore, options?: { workingDirectory?: string }): Promise<{
|
||||
session: CopilotAgentSession;
|
||||
mockSession: MockCopilotSession;
|
||||
progressEvents: IAgentProgressEvent[];
|
||||
}> {
|
||||
const progressEmitter = disposables.add(new Emitter<IAgentProgressEvent>());
|
||||
const progressEvents: IAgentProgressEvent[] = [];
|
||||
disposables.add(progressEmitter.event(e => progressEvents.push(e)));
|
||||
|
||||
const sessionUri = AgentSession.uri('copilot', 'test-session-1');
|
||||
const mockSession = new MockCopilotSession();
|
||||
|
||||
const factory: SessionWrapperFactory = async () => new CopilotSessionWrapper(mockSession as unknown as CopilotSession);
|
||||
|
||||
const services = new ServiceCollection();
|
||||
services.set(ILogService, new NullLogService());
|
||||
services.set(IFileService, { _serviceBrand: undefined } as IFileService);
|
||||
services.set(ISessionDataService, createMockSessionDataService());
|
||||
const instantiationService = disposables.add(new InstantiationService(services));
|
||||
|
||||
const session = disposables.add(instantiationService.createInstance(
|
||||
CopilotAgentSession,
|
||||
sessionUri,
|
||||
'test-session-1',
|
||||
options?.workingDirectory,
|
||||
progressEmitter,
|
||||
factory,
|
||||
));
|
||||
|
||||
await session.initializeSession();
|
||||
|
||||
return { session, mockSession, progressEvents };
|
||||
}
|
||||
|
||||
// ---- Tests ------------------------------------------------------------------
|
||||
|
||||
suite('CopilotAgentSession', () => {
|
||||
|
||||
const disposables = new DisposableStore();
|
||||
|
||||
teardown(() => disposables.clear());
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
// ---- permission handling ----
|
||||
|
||||
suite('permission handling', () => {
|
||||
|
||||
test('auto-approves read inside working directory', async () => {
|
||||
const { session } = await createAgentSession(disposables, { workingDirectory: '/workspace' });
|
||||
const result = await session.handlePermissionRequest({
|
||||
kind: 'read',
|
||||
path: '/workspace/src/file.ts',
|
||||
toolCallId: 'tc-1',
|
||||
});
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
});
|
||||
|
||||
test('does not auto-approve read outside working directory', async () => {
|
||||
const { session, progressEvents } = await createAgentSession(disposables, { workingDirectory: '/workspace' });
|
||||
|
||||
// Kick off permission request but don't await — it will block
|
||||
const resultPromise = session.handlePermissionRequest({
|
||||
kind: 'read',
|
||||
path: '/other/file.ts',
|
||||
toolCallId: 'tc-2',
|
||||
});
|
||||
|
||||
// Should have fired a tool_ready event
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
assert.strictEqual(progressEvents[0].type, 'tool_ready');
|
||||
|
||||
// Respond to it
|
||||
assert.ok(session.respondToPermissionRequest('tc-2', true));
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'approved');
|
||||
});
|
||||
|
||||
test('denies permission when no toolCallId', async () => {
|
||||
const { session } = await createAgentSession(disposables);
|
||||
const result = await session.handlePermissionRequest({ kind: 'write' });
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
});
|
||||
|
||||
test('denied-interactively when user denies', async () => {
|
||||
const { session, progressEvents } = await createAgentSession(disposables);
|
||||
const resultPromise = session.handlePermissionRequest({
|
||||
kind: 'shell',
|
||||
toolCallId: 'tc-3',
|
||||
});
|
||||
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
session.respondToPermissionRequest('tc-3', false);
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
});
|
||||
|
||||
test('pending permissions are denied on dispose', async () => {
|
||||
const { session } = await createAgentSession(disposables);
|
||||
const resultPromise = session.handlePermissionRequest({
|
||||
kind: 'write',
|
||||
toolCallId: 'tc-4',
|
||||
});
|
||||
|
||||
session.dispose();
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
});
|
||||
|
||||
test('pending permissions are denied on abort', async () => {
|
||||
const { session } = await createAgentSession(disposables);
|
||||
const resultPromise = session.handlePermissionRequest({
|
||||
kind: 'write',
|
||||
toolCallId: 'tc-5',
|
||||
});
|
||||
|
||||
await session.abort();
|
||||
const result = await resultPromise;
|
||||
assert.strictEqual(result.kind, 'denied-interactively-by-user');
|
||||
});
|
||||
|
||||
test('respondToPermissionRequest returns false for unknown id', async () => {
|
||||
const { session } = await createAgentSession(disposables);
|
||||
assert.strictEqual(session.respondToPermissionRequest('unknown-id', true), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- event mapping ----
|
||||
|
||||
suite('event mapping', () => {
|
||||
|
||||
test('tool_start event is mapped for non-hidden tools', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('tool.execution_start', {
|
||||
toolCallId: 'tc-10',
|
||||
toolName: 'bash',
|
||||
arguments: { command: 'echo hello' },
|
||||
} as SessionEventPayload<'tool.execution_start'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
assert.strictEqual(progressEvents[0].type, 'tool_start');
|
||||
if (progressEvents[0].type === 'tool_start') {
|
||||
assert.strictEqual(progressEvents[0].toolCallId, 'tc-10');
|
||||
assert.strictEqual(progressEvents[0].toolName, 'bash');
|
||||
}
|
||||
});
|
||||
|
||||
test('hidden tools are not emitted as tool_start', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('tool.execution_start', {
|
||||
toolCallId: 'tc-11',
|
||||
toolName: 'report_intent',
|
||||
} as SessionEventPayload<'tool.execution_start'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 0);
|
||||
});
|
||||
|
||||
test('tool_complete event produces past-tense message', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
|
||||
// First fire tool_start so it's tracked
|
||||
mockSession.fire('tool.execution_start', {
|
||||
toolCallId: 'tc-12',
|
||||
toolName: 'bash',
|
||||
arguments: { command: 'ls' },
|
||||
} as SessionEventPayload<'tool.execution_start'>['data']);
|
||||
|
||||
// Then fire complete
|
||||
mockSession.fire('tool.execution_complete', {
|
||||
toolCallId: 'tc-12',
|
||||
success: true,
|
||||
result: { content: 'file1.ts\nfile2.ts' },
|
||||
} as SessionEventPayload<'tool.execution_complete'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 2);
|
||||
assert.strictEqual(progressEvents[1].type, 'tool_complete');
|
||||
if (progressEvents[1].type === 'tool_complete') {
|
||||
assert.strictEqual(progressEvents[1].toolCallId, 'tc-12');
|
||||
assert.ok(progressEvents[1].result.success);
|
||||
assert.ok(progressEvents[1].result.pastTenseMessage);
|
||||
}
|
||||
});
|
||||
|
||||
test('tool_complete for untracked tool is ignored', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('tool.execution_complete', {
|
||||
toolCallId: 'tc-untracked',
|
||||
success: true,
|
||||
} as SessionEventPayload<'tool.execution_complete'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 0);
|
||||
});
|
||||
|
||||
test('idle event is forwarded', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
assert.strictEqual(progressEvents[0].type, 'idle');
|
||||
});
|
||||
|
||||
test('error event is forwarded', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('session.error', {
|
||||
errorType: 'TestError',
|
||||
message: 'something went wrong',
|
||||
stack: 'Error: something went wrong',
|
||||
} as SessionEventPayload<'session.error'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
assert.strictEqual(progressEvents[0].type, 'error');
|
||||
if (progressEvents[0].type === 'error') {
|
||||
assert.strictEqual(progressEvents[0].errorType, 'TestError');
|
||||
assert.strictEqual(progressEvents[0].message, 'something went wrong');
|
||||
}
|
||||
});
|
||||
|
||||
test('message delta is forwarded', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('assistant.message_delta', {
|
||||
messageId: 'msg-1',
|
||||
deltaContent: 'Hello ',
|
||||
} as SessionEventPayload<'assistant.message_delta'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
assert.strictEqual(progressEvents[0].type, 'delta');
|
||||
if (progressEvents[0].type === 'delta') {
|
||||
assert.strictEqual(progressEvents[0].content, 'Hello ');
|
||||
}
|
||||
});
|
||||
|
||||
test('complete message with tool requests is forwarded', async () => {
|
||||
const { mockSession, progressEvents } = await createAgentSession(disposables);
|
||||
mockSession.fire('assistant.message', {
|
||||
messageId: 'msg-2',
|
||||
content: 'Let me help you.',
|
||||
toolRequests: [{
|
||||
toolCallId: 'tc-20',
|
||||
name: 'bash',
|
||||
arguments: { command: 'ls' },
|
||||
type: 'function',
|
||||
}],
|
||||
} as SessionEventPayload<'assistant.message'>['data']);
|
||||
|
||||
assert.strictEqual(progressEvents.length, 1);
|
||||
assert.strictEqual(progressEvents[0].type, 'message');
|
||||
if (progressEvents[0].type === 'message') {
|
||||
assert.strictEqual(progressEvents[0].content, 'Let me help you.');
|
||||
assert.strictEqual(progressEvents[0].toolRequests?.length, 1);
|
||||
assert.strictEqual(progressEvents[0].toolRequests?.[0].toolCallId, 'tc-20');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user