mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-18 07:47:23 +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:
@@ -28,9 +28,13 @@ import product from '../../product/common/product.js';
|
|||||||
import { IProductService } from '../../product/common/productService.js';
|
import { IProductService } from '../../product/common/productService.js';
|
||||||
import { localize } from '../../../nls.js';
|
import { localize } from '../../../nls.js';
|
||||||
import { FileService } from '../../files/common/fileService.js';
|
import { FileService } from '../../files/common/fileService.js';
|
||||||
|
import { IFileService } from '../../files/common/files.js';
|
||||||
import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js';
|
import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.js';
|
||||||
import { Schemas } from '../../../base/common/network.js';
|
import { Schemas } from '../../../base/common/network.js';
|
||||||
|
import { InstantiationService } from '../../instantiation/common/instantiationService.js';
|
||||||
|
import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
|
||||||
import { SessionDataService } from './sessionDataService.js';
|
import { SessionDataService } from './sessionDataService.js';
|
||||||
|
import { ISessionDataService } from '../common/sessionDataService.js';
|
||||||
|
|
||||||
// Entry point for the agent host utility process.
|
// Entry point for the agent host utility process.
|
||||||
// Sets up IPC, logging, and registers agent providers (Copilot).
|
// Sets up IPC, logging, and registers agent providers (Copilot).
|
||||||
@@ -70,7 +74,12 @@ function startAgentHost(): void {
|
|||||||
let agentService: AgentService;
|
let agentService: AgentService;
|
||||||
try {
|
try {
|
||||||
agentService = new AgentService(logService, fileService, sessionDataService);
|
agentService = new AgentService(logService, fileService, sessionDataService);
|
||||||
agentService.registerProvider(new CopilotAgent(logService, fileService, sessionDataService));
|
const diServices = new ServiceCollection();
|
||||||
|
diServices.set(ILogService, logService);
|
||||||
|
diServices.set(IFileService, fileService);
|
||||||
|
diServices.set(ISessionDataService, sessionDataService);
|
||||||
|
const instantiationService = new InstantiationService(diServices);
|
||||||
|
agentService.registerProvider(instantiationService.createInstance(CopilotAgent));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logService.error('Failed to create AgentService', err);
|
logService.error('Failed to create AgentService', err);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -3,33 +3,20 @@
|
|||||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
*--------------------------------------------------------------------------------------------*/
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
import { CopilotClient, CopilotSession } from '@github/copilot-sdk';
|
import { CopilotClient } from '@github/copilot-sdk';
|
||||||
import { rgPath } from '@vscode/ripgrep';
|
import { rgPath } from '@vscode/ripgrep';
|
||||||
import { DeferredPromise } from '../../../../base/common/async.js';
|
|
||||||
import { Emitter } from '../../../../base/common/event.js';
|
import { Emitter } from '../../../../base/common/event.js';
|
||||||
import { Disposable, DisposableMap, IReference } from '../../../../base/common/lifecycle.js';
|
import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
|
||||||
import { FileAccess } from '../../../../base/common/network.js';
|
import { FileAccess } from '../../../../base/common/network.js';
|
||||||
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
|
import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js';
|
||||||
import { delimiter, dirname } from '../../../../base/common/path.js';
|
import { delimiter, dirname } from '../../../../base/common/path.js';
|
||||||
import { URI } from '../../../../base/common/uri.js';
|
import { URI } from '../../../../base/common/uri.js';
|
||||||
import { IFileService } from '../../../files/common/files.js';
|
import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
|
||||||
import { ILogService } from '../../../log/common/log.js';
|
import { ILogService } from '../../../log/common/log.js';
|
||||||
import { localize } from '../../../../nls.js';
|
|
||||||
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
||||||
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
import { type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js';
|
||||||
import { ToolResultContentType, type IPendingMessage, type IToolResultContent, type PolicyState } from '../../common/state/sessionState.js';
|
import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js';
|
||||||
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||||
import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js';
|
|
||||||
import { FileEditTracker } from './fileEditTracker.js';
|
|
||||||
import { mapSessionEvents } from './mapSessionEvents.js';
|
|
||||||
|
|
||||||
function tryStringify(value: unknown): string | undefined {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent provider backed by the Copilot SDK {@link CopilotClient}.
|
* Agent provider backed by the Copilot SDK {@link CopilotClient}.
|
||||||
@@ -43,22 +30,11 @@ export class CopilotAgent extends Disposable implements IAgent {
|
|||||||
private _client: CopilotClient | undefined;
|
private _client: CopilotClient | undefined;
|
||||||
private _clientStarting: Promise<CopilotClient> | undefined;
|
private _clientStarting: Promise<CopilotClient> | undefined;
|
||||||
private _githubToken: string | undefined;
|
private _githubToken: string | undefined;
|
||||||
private readonly _sessions = this._register(new DisposableMap<string, CopilotSessionWrapper>());
|
private readonly _sessions = this._register(new DisposableMap<string, CopilotAgentSession>());
|
||||||
/** Tracks active tool invocations so we can produce past-tense messages on completion. Keyed by `sessionId:toolCallId`. */
|
|
||||||
private readonly _activeToolCalls = new Map<string, { toolName: string; displayName: string; parameters: Record<string, unknown> | undefined }>();
|
|
||||||
/** Pending permission requests awaiting a renderer-side decision. Keyed by requestId. */
|
|
||||||
private readonly _pendingPermissions = new Map<string, { sessionId: string; deferred: DeferredPromise<boolean> }>();
|
|
||||||
/** Working directory per session, used when resuming. */
|
|
||||||
private readonly _sessionWorkingDirs = new Map<string, string>();
|
|
||||||
/** File edit trackers per session, keyed by raw session ID. */
|
|
||||||
private readonly _editTrackers = new Map<string, FileEditTracker>();
|
|
||||||
/** Session database references, keyed by raw session ID. */
|
|
||||||
private readonly _sessionDatabases = this._register(new DisposableMap<string, IReference<ISessionDatabase>>());
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ILogService private readonly _logService: ILogService,
|
@ILogService private readonly _logService: ILogService,
|
||||||
@IFileService private readonly _fileService: IFileService,
|
@IInstantiationService private readonly _instantiationService: IInstantiationService,
|
||||||
@ISessionDataService private readonly _sessionDataService: ISessionDataService,
|
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -203,45 +179,34 @@ export class CopilotAgent extends Disposable implements IAgent {
|
|||||||
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
|
async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
|
||||||
this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`);
|
this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`);
|
||||||
const client = await this._ensureClient();
|
const client = await this._ensureClient();
|
||||||
|
|
||||||
|
const factory: SessionWrapperFactory = async callbacks => {
|
||||||
const raw = await client.createSession({
|
const raw = await client.createSession({
|
||||||
model: config?.model,
|
model: config?.model,
|
||||||
sessionId: config?.session ? AgentSession.id(config.session) : undefined,
|
sessionId: config?.session ? AgentSession.id(config.session) : undefined,
|
||||||
streaming: true,
|
streaming: true,
|
||||||
workingDirectory: config?.workingDirectory,
|
workingDirectory: config?.workingDirectory,
|
||||||
onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation),
|
onPermissionRequest: callbacks.onPermissionRequest,
|
||||||
hooks: this._createSessionHooks(),
|
hooks: callbacks.hooks,
|
||||||
});
|
});
|
||||||
|
return new CopilotSessionWrapper(raw);
|
||||||
|
};
|
||||||
|
|
||||||
const wrapper = this._trackSession(raw);
|
const agentSession = this._createAgentSession(factory, config?.workingDirectory, config?.session ? AgentSession.id(config.session) : undefined);
|
||||||
const session = AgentSession.uri(this.id, wrapper.sessionId);
|
await agentSession.initializeSession();
|
||||||
if (config?.workingDirectory) {
|
|
||||||
this._sessionWorkingDirs.set(wrapper.sessionId, config.workingDirectory);
|
const session = agentSession.sessionUri;
|
||||||
}
|
|
||||||
this._logService.info(`[Copilot] Session created: ${session.toString()}`);
|
this._logService.info(`[Copilot] Session created: ${session.toString()}`);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise<void> {
|
async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise<void> {
|
||||||
const sessionId = AgentSession.id(session);
|
const sessionId = AgentSession.id(session);
|
||||||
this._logService.info(`[Copilot:${sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`);
|
|
||||||
const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId);
|
const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId);
|
||||||
this._logService.info(`[Copilot:${sessionId}] Found session wrapper, calling session.send()...`);
|
await entry.send(prompt, attachments);
|
||||||
|
|
||||||
const sdkAttachments = attachments?.map(a => {
|
|
||||||
if (a.type === 'selection') {
|
|
||||||
return { type: 'selection' as const, filePath: a.path, displayName: a.displayName ?? a.path, text: a.text, selection: a.selection };
|
|
||||||
}
|
|
||||||
return { type: a.type, path: a.path, displayName: a.displayName };
|
|
||||||
});
|
|
||||||
if (sdkAttachments?.length) {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await entry.session.send({ prompt, attachments: sdkAttachments });
|
setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void {
|
||||||
this._logService.info(`[Copilot:${sessionId}] session.send() returned`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void {
|
|
||||||
const sessionId = AgentSession.id(session);
|
const sessionId = AgentSession.id(session);
|
||||||
const entry = this._sessions.get(sessionId);
|
const entry = this._sessions.get(sessionId);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
@@ -251,13 +216,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
|||||||
|
|
||||||
// Steering: send with mode 'immediate' so the SDK injects it mid-turn
|
// Steering: send with mode 'immediate' so the SDK injects it mid-turn
|
||||||
if (steeringMessage) {
|
if (steeringMessage) {
|
||||||
this._logService.info(`[Copilot:${sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`);
|
entry.sendSteering(steeringMessage);
|
||||||
entry.session.send({
|
|
||||||
prompt: steeringMessage.userMessage.text,
|
|
||||||
mode: 'immediate',
|
|
||||||
}).catch(err => {
|
|
||||||
this._logService.error(`[Copilot:${sessionId}] Steering message failed`, err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queued messages are consumed by the server (AgentSideEffects)
|
// Queued messages are consumed by the server (AgentSideEffects)
|
||||||
@@ -271,33 +230,19 @@ export class CopilotAgent extends Disposable implements IAgent {
|
|||||||
if (!entry) {
|
if (!entry) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
return entry.getMessages();
|
||||||
const events = await entry.session.getMessages();
|
|
||||||
let db: ISessionDatabase | undefined;
|
|
||||||
try {
|
|
||||||
db = this._getSessionDatabase(sessionId);
|
|
||||||
} catch {
|
|
||||||
// Database may not exist yet — that's fine
|
|
||||||
}
|
|
||||||
return mapSessionEvents(session, db, events);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async disposeSession(session: URI): Promise<void> {
|
async disposeSession(session: URI): Promise<void> {
|
||||||
const sessionId = AgentSession.id(session);
|
const sessionId = AgentSession.id(session);
|
||||||
this._sessions.deleteAndDispose(sessionId);
|
this._sessions.deleteAndDispose(sessionId);
|
||||||
this._clearToolCallsForSession(sessionId);
|
|
||||||
this._sessionWorkingDirs.delete(sessionId);
|
|
||||||
this._sessionDatabases.deleteAndDispose(sessionId);
|
|
||||||
this._denyPendingPermissionsForSession(sessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async abortSession(session: URI): Promise<void> {
|
async abortSession(session: URI): Promise<void> {
|
||||||
const sessionId = AgentSession.id(session);
|
const sessionId = AgentSession.id(session);
|
||||||
const entry = this._sessions.get(sessionId);
|
const entry = this._sessions.get(sessionId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
this._logService.info(`[Copilot:${sessionId}] Aborting session...`);
|
await entry.abort();
|
||||||
this._denyPendingPermissionsForSession(sessionId);
|
|
||||||
await entry.session.abort();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,27 +250,22 @@ export class CopilotAgent extends Disposable implements IAgent {
|
|||||||
const sessionId = AgentSession.id(session);
|
const sessionId = AgentSession.id(session);
|
||||||
const entry = this._sessions.get(sessionId);
|
const entry = this._sessions.get(sessionId);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
this._logService.info(`[Copilot:${sessionId}] Changing model to: ${model}`);
|
await entry.setModel(model);
|
||||||
await entry.session.setModel(model);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
this._logService.info('[Copilot] Shutting down...');
|
this._logService.info('[Copilot] Shutting down...');
|
||||||
this._sessions.clearAndDisposeAll();
|
this._sessions.clearAndDisposeAll();
|
||||||
this._activeToolCalls.clear();
|
|
||||||
this._sessionWorkingDirs.clear();
|
|
||||||
this._denyPendingPermissions();
|
|
||||||
this._sessionDatabases.clearAndDisposeAll();
|
|
||||||
await this._client?.stop();
|
await this._client?.stop();
|
||||||
this._client = undefined;
|
this._client = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
respondToPermissionRequest(requestId: string, approved: boolean): void {
|
respondToPermissionRequest(requestId: string, approved: boolean): void {
|
||||||
const entry = this._pendingPermissions.get(requestId);
|
for (const [, session] of this._sessions) {
|
||||||
if (entry) {
|
if (session.respondToPermissionRequest(requestId, approved)) {
|
||||||
this._pendingPermissions.delete(requestId);
|
return;
|
||||||
entry.deferred.complete(approved);
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,477 +279,47 @@ export class CopilotAgent extends Disposable implements IAgent {
|
|||||||
// ---- helpers ------------------------------------------------------------
|
// ---- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles a permission request from the SDK by firing a `tool_ready` event
|
* Creates a {@link CopilotAgentSession}, registers it in the sessions map,
|
||||||
* (which transitions the tool to PendingConfirmation) and waiting for the
|
* and returns it. The caller must call {@link CopilotAgentSession.initializeSession}
|
||||||
* side-effects layer to respond via respondToPermissionRequest.
|
* to wire up the SDK session.
|
||||||
*/
|
*/
|
||||||
private async _handlePermissionRequest(
|
private _createAgentSession(wrapperFactory: SessionWrapperFactory, workingDirectory: string | undefined, sessionIdOverride?: string): CopilotAgentSession {
|
||||||
request: { kind: string; toolCallId?: string;[key: string]: unknown },
|
const rawId = sessionIdOverride ?? crypto.randomUUID();
|
||||||
invocation: { sessionId: string },
|
const sessionUri = AgentSession.uri(this.id, rawId);
|
||||||
): Promise<{ kind: 'approved' | 'denied-interactively-by-user' }> {
|
|
||||||
const session = AgentSession.uri(this.id, invocation.sessionId);
|
|
||||||
|
|
||||||
this._logService.info(`[Copilot:${invocation.sessionId}] Permission request: kind=${request.kind}`);
|
const agentSession = this._instantiationService.createInstance(
|
||||||
|
CopilotAgentSession,
|
||||||
|
sessionUri,
|
||||||
|
rawId,
|
||||||
|
workingDirectory,
|
||||||
|
this._onDidSessionProgress,
|
||||||
|
wrapperFactory,
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-approve reads inside the working directory
|
this._sessions.set(rawId, agentSession);
|
||||||
if (request.kind === 'read') {
|
return agentSession;
|
||||||
const requestPath = typeof request.path === 'string' ? request.path : undefined;
|
|
||||||
const workingDir = this._sessionWorkingDirs.get(invocation.sessionId);
|
|
||||||
if (requestPath && workingDir && requestPath.startsWith(workingDir)) {
|
|
||||||
this._logService.trace(`[Copilot:${invocation.sessionId}] Auto-approving read inside working directory: ${requestPath}`);
|
|
||||||
return { kind: 'approved' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallId = request.toolCallId;
|
private async _resumeSession(sessionId: string): Promise<CopilotAgentSession> {
|
||||||
if (!toolCallId) {
|
|
||||||
// TODO: handle permission requests without a toolCallId by creating a synthetic tool call
|
|
||||||
this._logService.warn(`[Copilot:${invocation.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`);
|
|
||||||
return { kind: 'denied-interactively-by-user' };
|
|
||||||
}
|
|
||||||
|
|
||||||
this._logService.info(`[Copilot:${invocation.sessionId}] Requesting confirmation for tool call: ${toolCallId}`);
|
|
||||||
|
|
||||||
const deferred = new DeferredPromise<boolean>();
|
|
||||||
this._pendingPermissions.set(toolCallId, { sessionId: invocation.sessionId, deferred });
|
|
||||||
|
|
||||||
// Derive display information from the permission request kind
|
|
||||||
const { confirmationTitle, invocationMessage, toolInput } = this._getPermissionDisplay(request);
|
|
||||||
|
|
||||||
// Fire a tool_ready event to transition the tool to PendingConfirmation
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'tool_ready',
|
|
||||||
toolCallId,
|
|
||||||
invocationMessage,
|
|
||||||
toolInput,
|
|
||||||
confirmationTitle,
|
|
||||||
permissionKind: request.kind,
|
|
||||||
permissionPath: typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined),
|
|
||||||
});
|
|
||||||
|
|
||||||
const approved = await deferred.p;
|
|
||||||
this._logService.info(`[Copilot:${invocation.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`);
|
|
||||||
return { kind: approved ? 'approved' : 'denied-interactively-by-user' };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Derives display fields from a permission request for the tool confirmation UI.
|
|
||||||
*/
|
|
||||||
private _getPermissionDisplay(request: { kind: string;[key: string]: unknown }): {
|
|
||||||
confirmationTitle: string;
|
|
||||||
invocationMessage: string;
|
|
||||||
toolInput?: string;
|
|
||||||
} {
|
|
||||||
const path = typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined);
|
|
||||||
const fullCommandText = typeof request.fullCommandText === 'string' ? request.fullCommandText : undefined;
|
|
||||||
const intention = typeof request.intention === 'string' ? request.intention : undefined;
|
|
||||||
const serverName = typeof request.serverName === 'string' ? request.serverName : undefined;
|
|
||||||
const toolName = typeof request.toolName === 'string' ? request.toolName : undefined;
|
|
||||||
|
|
||||||
switch (request.kind) {
|
|
||||||
case 'shell':
|
|
||||||
return {
|
|
||||||
confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"),
|
|
||||||
invocationMessage: intention ?? localize('copilot.permission.shell.message', "Run command"),
|
|
||||||
toolInput: fullCommandText,
|
|
||||||
};
|
|
||||||
case 'write':
|
|
||||||
return {
|
|
||||||
confirmationTitle: localize('copilot.permission.write.title', "Write file"),
|
|
||||||
invocationMessage: path ? localize('copilot.permission.write.message', "Edit {0}", path) : localize('copilot.permission.write.messageGeneric', "Edit file"),
|
|
||||||
toolInput: tryStringify(path ? { path } : request) ?? undefined,
|
|
||||||
};
|
|
||||||
case 'mcp': {
|
|
||||||
const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool");
|
|
||||||
return {
|
|
||||||
confirmationTitle: serverName ? `${serverName}: ${title}` : title,
|
|
||||||
invocationMessage: serverName ? `${serverName}: ${title}` : title,
|
|
||||||
toolInput: tryStringify({ serverName, toolName }) ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case 'read':
|
|
||||||
return {
|
|
||||||
confirmationTitle: localize('copilot.permission.read.title', "Read file"),
|
|
||||||
invocationMessage: intention ?? localize('copilot.permission.read.message', "Read file"),
|
|
||||||
toolInput: tryStringify(path ? { path, intention } : request) ?? undefined,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
confirmationTitle: localize('copilot.permission.default.title', "Permission request"),
|
|
||||||
invocationMessage: localize('copilot.permission.default.message', "Permission request"),
|
|
||||||
toolInput: tryStringify(request) ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _clearToolCallsForSession(sessionId: string): void {
|
|
||||||
const prefix = `${sessionId}:`;
|
|
||||||
for (const key of this._activeToolCalls.keys()) {
|
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
this._activeToolCalls.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _getSessionDatabase(rawSessionId: string): ISessionDatabase {
|
|
||||||
let ref = this._sessionDatabases.get(rawSessionId);
|
|
||||||
if (!ref) {
|
|
||||||
const session = AgentSession.uri(this.id, rawSessionId);
|
|
||||||
ref = this._sessionDataService.openDatabase(session);
|
|
||||||
this._sessionDatabases.set(rawSessionId, ref);
|
|
||||||
}
|
|
||||||
return ref.object;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _getOrCreateEditTracker(rawSessionId: string): FileEditTracker {
|
|
||||||
let tracker = this._editTrackers.get(rawSessionId);
|
|
||||||
if (!tracker) {
|
|
||||||
const session = AgentSession.uri(this.id, rawSessionId);
|
|
||||||
const db = this._getSessionDatabase(rawSessionId);
|
|
||||||
tracker = new FileEditTracker(session.toString(), db, this._fileService, this._logService);
|
|
||||||
this._editTrackers.set(rawSessionId, tracker);
|
|
||||||
}
|
|
||||||
return tracker;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates SDK session hooks for pre/post tool use. The `onPreToolUse`
|
|
||||||
* hook snapshots files before edit tools run. The `onPostToolUse` hook
|
|
||||||
* snapshots the after-content so that it's ready synchronously when
|
|
||||||
* `onToolComplete` fires.
|
|
||||||
*/
|
|
||||||
private _createSessionHooks() {
|
|
||||||
return {
|
|
||||||
onPreToolUse: async (input: { toolName: string; toolArgs: unknown }, invocation: { sessionId: string }) => {
|
|
||||||
if (isEditTool(input.toolName)) {
|
|
||||||
const filePath = getEditFilePath(input.toolArgs);
|
|
||||||
if (filePath) {
|
|
||||||
const tracker = this._getOrCreateEditTracker(invocation.sessionId);
|
|
||||||
await tracker.trackEditStart(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPostToolUse: async (input: { toolName: string; toolArgs: unknown }, invocation: { sessionId: string }) => {
|
|
||||||
if (isEditTool(input.toolName)) {
|
|
||||||
const filePath = getEditFilePath(input.toolArgs);
|
|
||||||
if (filePath) {
|
|
||||||
const tracker = this._editTrackers.get(invocation.sessionId);
|
|
||||||
await tracker?.completeEdit(filePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private _trackSession(raw: CopilotSession, sessionIdOverride?: string): CopilotSessionWrapper {
|
|
||||||
const wrapper = new CopilotSessionWrapper(raw);
|
|
||||||
const rawId = sessionIdOverride ?? wrapper.sessionId;
|
|
||||||
const session = AgentSession.uri(this.id, rawId);
|
|
||||||
|
|
||||||
wrapper.onMessageDelta(e => {
|
|
||||||
this._logService.trace(`[Copilot:${rawId}] delta: ${e.data.deltaContent}`);
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'delta',
|
|
||||||
messageId: e.data.messageId,
|
|
||||||
content: e.data.deltaContent,
|
|
||||||
parentToolCallId: e.data.parentToolCallId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onMessage(e => {
|
|
||||||
this._logService.info(`[Copilot:${rawId}] Full message received: ${e.data.content.length} chars`);
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'message',
|
|
||||||
role: 'assistant',
|
|
||||||
messageId: e.data.messageId,
|
|
||||||
content: e.data.content,
|
|
||||||
toolRequests: e.data.toolRequests?.map(tr => ({
|
|
||||||
toolCallId: tr.toolCallId,
|
|
||||||
name: tr.name,
|
|
||||||
arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined,
|
|
||||||
type: tr.type,
|
|
||||||
})),
|
|
||||||
reasoningOpaque: e.data.reasoningOpaque,
|
|
||||||
reasoningText: e.data.reasoningText,
|
|
||||||
encryptedContent: e.data.encryptedContent,
|
|
||||||
parentToolCallId: e.data.parentToolCallId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onToolStart(e => {
|
|
||||||
if (isHiddenTool(e.data.toolName)) {
|
|
||||||
this._logService.trace(`[Copilot:${rawId}] Tool started (hidden): ${e.data.toolName}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._logService.info(`[Copilot:${rawId}] Tool started: ${e.data.toolName}`);
|
|
||||||
const toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined;
|
|
||||||
let parameters: Record<string, unknown> | undefined;
|
|
||||||
if (toolArgs) {
|
|
||||||
try { parameters = JSON.parse(toolArgs) as Record<string, unknown>; } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
const displayName = getToolDisplayName(e.data.toolName);
|
|
||||||
const trackingKey = `${rawId}:${e.data.toolCallId}`;
|
|
||||||
this._activeToolCalls.set(trackingKey, { toolName: e.data.toolName, displayName, parameters });
|
|
||||||
const toolKind = getToolKind(e.data.toolName);
|
|
||||||
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'tool_start',
|
|
||||||
toolCallId: e.data.toolCallId,
|
|
||||||
toolName: e.data.toolName,
|
|
||||||
displayName,
|
|
||||||
invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters),
|
|
||||||
toolInput: getToolInputString(e.data.toolName, parameters, toolArgs),
|
|
||||||
toolKind,
|
|
||||||
language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined,
|
|
||||||
toolArguments: toolArgs,
|
|
||||||
mcpServerName: e.data.mcpServerName,
|
|
||||||
mcpToolName: e.data.mcpToolName,
|
|
||||||
parentToolCallId: e.data.parentToolCallId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let turnId: string = '';
|
|
||||||
wrapper.onTurnStart(e => {
|
|
||||||
turnId = e.data.turnId;
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onToolComplete(e => {
|
|
||||||
const trackingKey = `${rawId}:${e.data.toolCallId}`;
|
|
||||||
const tracked = this._activeToolCalls.get(trackingKey);
|
|
||||||
if (!tracked) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._logService.info(`[Copilot:${rawId}] Tool completed: ${e.data.toolCallId}`);
|
|
||||||
this._activeToolCalls.delete(trackingKey);
|
|
||||||
const displayName = tracked.displayName;
|
|
||||||
const toolOutput = e.data.error?.message ?? e.data.result?.content;
|
|
||||||
|
|
||||||
const content: IToolResultContent[] = [];
|
|
||||||
if (toolOutput !== undefined) {
|
|
||||||
content.push({ type: ToolResultContentType.Text, text: toolOutput });
|
|
||||||
}
|
|
||||||
|
|
||||||
// File edit data was already prepared by the onPostToolUse hook
|
|
||||||
const tracker = this._editTrackers.get(rawId);
|
|
||||||
const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined;
|
|
||||||
if (tracker && filePath) {
|
|
||||||
const fileEdit = tracker.takeCompletedEdit(turnId, e.data.toolCallId, filePath);
|
|
||||||
if (fileEdit) {
|
|
||||||
content.push(fileEdit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'tool_complete',
|
|
||||||
toolCallId: e.data.toolCallId,
|
|
||||||
result: {
|
|
||||||
success: e.data.success,
|
|
||||||
pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success),
|
|
||||||
content: content.length > 0 ? content : undefined,
|
|
||||||
error: e.data.error,
|
|
||||||
},
|
|
||||||
isUserRequested: e.data.isUserRequested,
|
|
||||||
toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined,
|
|
||||||
parentToolCallId: e.data.parentToolCallId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onIdle(() => {
|
|
||||||
this._logService.info(`[Copilot:${rawId}] Session idle`);
|
|
||||||
this._onDidSessionProgress.fire({ session, type: 'idle' });
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionError(e => {
|
|
||||||
this._logService.error(`[Copilot:${rawId}] Session error: ${e.data.errorType} - ${e.data.message}`);
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'error',
|
|
||||||
errorType: e.data.errorType,
|
|
||||||
message: e.data.message,
|
|
||||||
stack: e.data.stack,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onUsage(e => {
|
|
||||||
this._logService.trace(`[Copilot:${rawId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}`);
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'usage',
|
|
||||||
inputTokens: e.data.inputTokens,
|
|
||||||
outputTokens: e.data.outputTokens,
|
|
||||||
model: e.data.model,
|
|
||||||
cacheReadTokens: e.data.cacheReadTokens,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onReasoningDelta(e => {
|
|
||||||
this._logService.trace(`[Copilot:${rawId}] Reasoning delta: ${e.data.deltaContent.length} chars`);
|
|
||||||
this._onDidSessionProgress.fire({
|
|
||||||
session,
|
|
||||||
type: 'reasoning',
|
|
||||||
content: e.data.deltaContent,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this._subscribeForLogging(wrapper, rawId);
|
|
||||||
|
|
||||||
this._sessions.set(rawId, wrapper);
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _subscribeForLogging(wrapper: CopilotSessionWrapper, sessionId: string): void {
|
|
||||||
wrapper.onSessionStart(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionResume(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionInfo(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionModelChange(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionHandoff(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionTruncation(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionSnapshotRewind(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionShutdown(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionUsageInfo(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionCompactionStart(() => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Compaction started`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSessionCompactionComplete(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onUserMessage(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onPendingMessagesModified(() => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onTurnStart(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onIntent(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onReasoning(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onTurnEnd(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onAbort(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onToolUserRequested(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onToolPartialResult(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onToolProgress(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSkillInvoked(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSubagentStarted(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSubagentCompleted(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSubagentFailed(e => {
|
|
||||||
this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSubagentSelected(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onHookStart(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onHookEnd(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.onSystemMessage(e => {
|
|
||||||
this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _resumeSession(sessionId: string): Promise<CopilotSessionWrapper> {
|
|
||||||
this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`);
|
this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`);
|
||||||
const client = await this._ensureClient();
|
const client = await this._ensureClient();
|
||||||
|
|
||||||
|
const factory: SessionWrapperFactory = async callbacks => {
|
||||||
const raw = await client.resumeSession(sessionId, {
|
const raw = await client.resumeSession(sessionId, {
|
||||||
onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation),
|
onPermissionRequest: callbacks.onPermissionRequest,
|
||||||
workingDirectory: this._sessionWorkingDirs.get(sessionId),
|
workingDirectory: undefined,
|
||||||
hooks: this._createSessionHooks(),
|
hooks: callbacks.hooks,
|
||||||
});
|
});
|
||||||
return this._trackSession(raw, sessionId);
|
return new CopilotSessionWrapper(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentSession = this._createAgentSession(factory, undefined, sessionId);
|
||||||
|
await agentSession.initializeSession();
|
||||||
|
return agentSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
override dispose(): void {
|
override dispose(): void {
|
||||||
this._denyPendingPermissions();
|
|
||||||
this._client?.stop().catch(() => { /* best-effort */ });
|
this._client?.stop().catch(() => { /* best-effort */ });
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _denyPendingPermissions(): void {
|
|
||||||
for (const [, entry] of this._pendingPermissions) {
|
|
||||||
entry.deferred.complete(false);
|
|
||||||
}
|
|
||||||
this._pendingPermissions.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _denyPendingPermissionsForSession(sessionId: string): void {
|
|
||||||
for (const [requestId, entry] of this._pendingPermissions) {
|
|
||||||
if (entry.sessionId === sessionId) {
|
|
||||||
entry.deferred.complete(false);
|
|
||||||
this._pendingPermissions.delete(requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
573
src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Normal file
573
src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
import type { PermissionRequest, PermissionRequestResult } from '@github/copilot-sdk';
|
||||||
|
import { DeferredPromise } from '../../../../base/common/async.js';
|
||||||
|
import { Emitter } from '../../../../base/common/event.js';
|
||||||
|
import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||||
|
import { URI } from '../../../../base/common/uri.js';
|
||||||
|
import { IFileService } from '../../../files/common/files.js';
|
||||||
|
import { ILogService } from '../../../log/common/log.js';
|
||||||
|
import { localize } from '../../../../nls.js';
|
||||||
|
import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js';
|
||||||
|
import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
|
||||||
|
import { ToolResultContentType, type IPendingMessage, type IToolResultContent } from '../../common/state/sessionState.js';
|
||||||
|
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
|
||||||
|
import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js';
|
||||||
|
import { FileEditTracker } from './fileEditTracker.js';
|
||||||
|
import { mapSessionEvents } from './mapSessionEvents.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function that produces a {@link CopilotSessionWrapper}.
|
||||||
|
* Called by {@link CopilotAgentSession.initializeSession} with the
|
||||||
|
* session's permission handler and edit-tracking hooks so the factory
|
||||||
|
* can wire them into the SDK session it creates.
|
||||||
|
*
|
||||||
|
* In production, the factory calls `CopilotClient.createSession()` or
|
||||||
|
* `resumeSession()`. In tests, it returns a mock wrapper directly.
|
||||||
|
*/
|
||||||
|
export type SessionWrapperFactory = (callbacks: {
|
||||||
|
readonly onPermissionRequest: (request: PermissionRequest) => Promise<PermissionRequestResult>;
|
||||||
|
readonly hooks: {
|
||||||
|
readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise<void>;
|
||||||
|
readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise<void>;
|
||||||
|
};
|
||||||
|
}) => Promise<CopilotSessionWrapper>;
|
||||||
|
|
||||||
|
function tryStringify(value: unknown): string | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives display fields from a permission request for the tool confirmation UI.
|
||||||
|
*/
|
||||||
|
function getPermissionDisplay(request: { kind: string;[key: string]: unknown }): {
|
||||||
|
confirmationTitle: string;
|
||||||
|
invocationMessage: string;
|
||||||
|
toolInput?: string;
|
||||||
|
} {
|
||||||
|
const path = typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined);
|
||||||
|
const fullCommandText = typeof request.fullCommandText === 'string' ? request.fullCommandText : undefined;
|
||||||
|
const intention = typeof request.intention === 'string' ? request.intention : undefined;
|
||||||
|
const serverName = typeof request.serverName === 'string' ? request.serverName : undefined;
|
||||||
|
const toolName = typeof request.toolName === 'string' ? request.toolName : undefined;
|
||||||
|
|
||||||
|
switch (request.kind) {
|
||||||
|
case 'shell':
|
||||||
|
return {
|
||||||
|
confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"),
|
||||||
|
invocationMessage: intention ?? localize('copilot.permission.shell.message', "Run command"),
|
||||||
|
toolInput: fullCommandText,
|
||||||
|
};
|
||||||
|
case 'write':
|
||||||
|
return {
|
||||||
|
confirmationTitle: localize('copilot.permission.write.title', "Write file"),
|
||||||
|
invocationMessage: path ? localize('copilot.permission.write.message', "Edit {0}", path) : localize('copilot.permission.write.messageGeneric', "Edit file"),
|
||||||
|
toolInput: tryStringify(path ? { path } : request) ?? undefined,
|
||||||
|
};
|
||||||
|
case 'mcp': {
|
||||||
|
const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool");
|
||||||
|
return {
|
||||||
|
confirmationTitle: serverName ? `${serverName}: ${title}` : title,
|
||||||
|
invocationMessage: serverName ? `${serverName}: ${title}` : title,
|
||||||
|
toolInput: tryStringify({ serverName, toolName }) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'read':
|
||||||
|
return {
|
||||||
|
confirmationTitle: localize('copilot.permission.read.title', "Read file"),
|
||||||
|
invocationMessage: intention ?? localize('copilot.permission.read.message', "Read file"),
|
||||||
|
toolInput: tryStringify(path ? { path, intention } : request) ?? undefined,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
confirmationTitle: localize('copilot.permission.default.title', "Permission request"),
|
||||||
|
invocationMessage: localize('copilot.permission.default.message', "Permission request"),
|
||||||
|
toolInput: tryStringify(request) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates a single Copilot SDK session and all its associated bookkeeping.
|
||||||
|
*
|
||||||
|
* Created by {@link CopilotAgent}, one instance per active session. Disposing
|
||||||
|
* this class tears down all per-session resources (SDK wrapper, edit tracker,
|
||||||
|
* database reference, pending permissions).
|
||||||
|
*/
|
||||||
|
export class CopilotAgentSession extends Disposable {
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly sessionUri: URI;
|
||||||
|
|
||||||
|
/** Tracks active tool invocations so we can produce past-tense messages on completion. */
|
||||||
|
private readonly _activeToolCalls = new Map<string, { toolName: string; displayName: string; parameters: Record<string, unknown> | undefined }>();
|
||||||
|
/** Pending permission requests awaiting a renderer-side decision. */
|
||||||
|
private readonly _pendingPermissions = new Map<string, DeferredPromise<boolean>>();
|
||||||
|
/** File edit tracker for this session. */
|
||||||
|
private readonly _editTracker: FileEditTracker;
|
||||||
|
/** Session database reference. */
|
||||||
|
private readonly _databaseRef: IReference<ISessionDatabase>;
|
||||||
|
/** Turn ID tracked across tool events. */
|
||||||
|
private _turnId = '';
|
||||||
|
/** SDK session wrapper, set by {@link initializeSession}. */
|
||||||
|
private _wrapper!: CopilotSessionWrapper;
|
||||||
|
|
||||||
|
private readonly _workingDirectory: string | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
sessionUri: URI,
|
||||||
|
rawSessionId: string,
|
||||||
|
workingDirectory: string | undefined,
|
||||||
|
private readonly _onDidSessionProgress: Emitter<IAgentProgressEvent>,
|
||||||
|
private readonly _wrapperFactory: SessionWrapperFactory,
|
||||||
|
@IFileService private readonly _fileService: IFileService,
|
||||||
|
@ILogService private readonly _logService: ILogService,
|
||||||
|
@ISessionDataService sessionDataService: ISessionDataService,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.sessionId = rawSessionId;
|
||||||
|
this.sessionUri = sessionUri;
|
||||||
|
this._workingDirectory = workingDirectory;
|
||||||
|
|
||||||
|
this._databaseRef = sessionDataService.openDatabase(sessionUri);
|
||||||
|
this._register(toDisposable(() => this._databaseRef.dispose()));
|
||||||
|
|
||||||
|
this._editTracker = new FileEditTracker(sessionUri.toString(), this._databaseRef.object, this._fileService, this._logService);
|
||||||
|
|
||||||
|
this._register(toDisposable(() => this._denyPendingPermissions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates (or resumes) the SDK session via the injected factory and
|
||||||
|
* wires up all event listeners. Must be called exactly once after
|
||||||
|
* construction before using the session.
|
||||||
|
*/
|
||||||
|
async initializeSession(): Promise<void> {
|
||||||
|
this._wrapper = this._register(await this._wrapperFactory({
|
||||||
|
onPermissionRequest: request => this.handlePermissionRequest(request),
|
||||||
|
hooks: {
|
||||||
|
onPreToolUse: async input => {
|
||||||
|
if (isEditTool(input.toolName)) {
|
||||||
|
const filePath = getEditFilePath(input.toolArgs);
|
||||||
|
if (filePath) {
|
||||||
|
await this._editTracker.trackEditStart(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPostToolUse: async input => {
|
||||||
|
if (isEditTool(input.toolName)) {
|
||||||
|
const filePath = getEditFilePath(input.toolArgs);
|
||||||
|
if (filePath) {
|
||||||
|
await this._editTracker.completeEdit(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
this._subscribeToEvents();
|
||||||
|
this._subscribeForLogging();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- session operations -------------------------------------------------
|
||||||
|
|
||||||
|
async send(prompt: string, attachments?: IAgentAttachment[]): Promise<void> {
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`);
|
||||||
|
|
||||||
|
const sdkAttachments = attachments?.map(a => {
|
||||||
|
if (a.type === 'selection') {
|
||||||
|
return { type: 'selection' as const, filePath: a.path, displayName: a.displayName ?? a.path, text: a.text, selection: a.selection };
|
||||||
|
}
|
||||||
|
return { type: a.type, path: a.path, displayName: a.displayName };
|
||||||
|
});
|
||||||
|
if (sdkAttachments?.length) {
|
||||||
|
this._logService.trace(`[Copilot:${this.sessionId}] Attachments: ${JSON.stringify(sdkAttachments.map(a => ({ type: a.type, path: a.type === 'selection' ? a.filePath : a.path })))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._wrapper.session.send({ prompt, attachments: sdkAttachments });
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSteering(steeringMessage: IPendingMessage): void {
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`);
|
||||||
|
this._wrapper.session.send({
|
||||||
|
prompt: steeringMessage.userMessage.text,
|
||||||
|
mode: 'immediate',
|
||||||
|
}).catch(err => {
|
||||||
|
this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> {
|
||||||
|
const events = await this._wrapper.session.getMessages();
|
||||||
|
let db: ISessionDatabase | undefined;
|
||||||
|
try {
|
||||||
|
db = this._databaseRef.object;
|
||||||
|
} catch {
|
||||||
|
// Database may not exist yet — that's fine
|
||||||
|
}
|
||||||
|
return mapSessionEvents(this.sessionUri, db, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
async abort(): Promise<void> {
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`);
|
||||||
|
this._denyPendingPermissions();
|
||||||
|
await this._wrapper.session.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setModel(model: string): Promise<void> {
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] Changing model to: ${model}`);
|
||||||
|
await this._wrapper.session.setModel(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- permission handling ------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a permission request from the SDK by firing a `tool_ready` event
|
||||||
|
* (which transitions the tool to PendingConfirmation) and waiting for the
|
||||||
|
* side-effects layer to respond via {@link respondToPermissionRequest}.
|
||||||
|
*/
|
||||||
|
async handlePermissionRequest(
|
||||||
|
request: PermissionRequest,
|
||||||
|
): Promise<PermissionRequestResult> {
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] Permission request: kind=${request.kind}`);
|
||||||
|
|
||||||
|
// Auto-approve reads inside the working directory
|
||||||
|
if (request.kind === 'read') {
|
||||||
|
const requestPath = typeof request.path === 'string' ? request.path : undefined;
|
||||||
|
if (requestPath && this._workingDirectory && requestPath.startsWith(this._workingDirectory)) {
|
||||||
|
this._logService.trace(`[Copilot:${this.sessionId}] Auto-approving read inside working directory: ${requestPath}`);
|
||||||
|
return { kind: 'approved' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCallId = request.toolCallId;
|
||||||
|
if (!toolCallId) {
|
||||||
|
// TODO: handle permission requests without a toolCallId by creating a synthetic tool call
|
||||||
|
this._logService.warn(`[Copilot:${this.sessionId}] Permission request without toolCallId, auto-denying: kind=${request.kind}`);
|
||||||
|
return { kind: 'denied-interactively-by-user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] Requesting confirmation for tool call: ${toolCallId}`);
|
||||||
|
|
||||||
|
const deferred = new DeferredPromise<boolean>();
|
||||||
|
this._pendingPermissions.set(toolCallId, deferred);
|
||||||
|
|
||||||
|
// Derive display information from the permission request kind
|
||||||
|
const { confirmationTitle, invocationMessage, toolInput } = getPermissionDisplay(request);
|
||||||
|
|
||||||
|
// Fire a tool_ready event to transition the tool to PendingConfirmation
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session: this.sessionUri,
|
||||||
|
type: 'tool_ready',
|
||||||
|
toolCallId,
|
||||||
|
invocationMessage,
|
||||||
|
toolInput,
|
||||||
|
confirmationTitle,
|
||||||
|
permissionKind: request.kind,
|
||||||
|
permissionPath: typeof request.path === 'string' ? request.path : (typeof request.fileName === 'string' ? request.fileName : undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const approved = await deferred.p;
|
||||||
|
this._logService.info(`[Copilot:${this.sessionId}] Permission response: toolCallId=${toolCallId}, approved=${approved}`);
|
||||||
|
return { kind: approved ? 'approved' : 'denied-interactively-by-user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
respondToPermissionRequest(requestId: string, approved: boolean): boolean {
|
||||||
|
const deferred = this._pendingPermissions.get(requestId);
|
||||||
|
if (deferred) {
|
||||||
|
this._pendingPermissions.delete(requestId);
|
||||||
|
deferred.complete(approved);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- event wiring -------------------------------------------------------
|
||||||
|
|
||||||
|
private _subscribeToEvents(): void {
|
||||||
|
const wrapper = this._wrapper;
|
||||||
|
const sessionId = this.sessionId;
|
||||||
|
const session = this.sessionUri;
|
||||||
|
|
||||||
|
this._register(wrapper.onMessageDelta(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] delta: ${e.data.deltaContent}`);
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'delta',
|
||||||
|
messageId: e.data.messageId,
|
||||||
|
content: e.data.deltaContent,
|
||||||
|
parentToolCallId: e.data.parentToolCallId,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onMessage(e => {
|
||||||
|
this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`);
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
messageId: e.data.messageId,
|
||||||
|
content: e.data.content,
|
||||||
|
toolRequests: e.data.toolRequests?.map(tr => ({
|
||||||
|
toolCallId: tr.toolCallId,
|
||||||
|
name: tr.name,
|
||||||
|
arguments: tr.arguments !== undefined ? tryStringify(tr.arguments) : undefined,
|
||||||
|
type: tr.type,
|
||||||
|
})),
|
||||||
|
reasoningOpaque: e.data.reasoningOpaque,
|
||||||
|
reasoningText: e.data.reasoningText,
|
||||||
|
encryptedContent: e.data.encryptedContent,
|
||||||
|
parentToolCallId: e.data.parentToolCallId,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onToolStart(e => {
|
||||||
|
if (isHiddenTool(e.data.toolName)) {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`);
|
||||||
|
const toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined;
|
||||||
|
let parameters: Record<string, unknown> | undefined;
|
||||||
|
if (toolArgs) {
|
||||||
|
try { parameters = JSON.parse(toolArgs) as Record<string, unknown>; } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
const displayName = getToolDisplayName(e.data.toolName);
|
||||||
|
this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters });
|
||||||
|
const toolKind = getToolKind(e.data.toolName);
|
||||||
|
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'tool_start',
|
||||||
|
toolCallId: e.data.toolCallId,
|
||||||
|
toolName: e.data.toolName,
|
||||||
|
displayName,
|
||||||
|
invocationMessage: getInvocationMessage(e.data.toolName, displayName, parameters),
|
||||||
|
toolInput: getToolInputString(e.data.toolName, parameters, toolArgs),
|
||||||
|
toolKind,
|
||||||
|
language: toolKind === 'terminal' ? getShellLanguage(e.data.toolName) : undefined,
|
||||||
|
toolArguments: toolArgs,
|
||||||
|
mcpServerName: e.data.mcpServerName,
|
||||||
|
mcpToolName: e.data.mcpToolName,
|
||||||
|
parentToolCallId: e.data.parentToolCallId,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onTurnStart(e => {
|
||||||
|
this._turnId = e.data.turnId;
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onToolComplete(e => {
|
||||||
|
const tracked = this._activeToolCalls.get(e.data.toolCallId);
|
||||||
|
if (!tracked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`);
|
||||||
|
this._activeToolCalls.delete(e.data.toolCallId);
|
||||||
|
const displayName = tracked.displayName;
|
||||||
|
const toolOutput = e.data.error?.message ?? e.data.result?.content;
|
||||||
|
|
||||||
|
const content: IToolResultContent[] = [];
|
||||||
|
if (toolOutput !== undefined) {
|
||||||
|
content.push({ type: ToolResultContentType.Text, text: toolOutput });
|
||||||
|
}
|
||||||
|
|
||||||
|
// File edit data was already prepared by the onPostToolUse hook
|
||||||
|
const filePath = isEditTool(tracked.toolName) ? getEditFilePath(tracked.parameters) : undefined;
|
||||||
|
if (filePath) {
|
||||||
|
const fileEdit = this._editTracker.takeCompletedEdit(this._turnId, e.data.toolCallId, filePath);
|
||||||
|
if (fileEdit) {
|
||||||
|
content.push(fileEdit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'tool_complete',
|
||||||
|
toolCallId: e.data.toolCallId,
|
||||||
|
result: {
|
||||||
|
success: e.data.success,
|
||||||
|
pastTenseMessage: getPastTenseMessage(tracked.toolName, displayName, tracked.parameters, e.data.success),
|
||||||
|
content: content.length > 0 ? content : undefined,
|
||||||
|
error: e.data.error,
|
||||||
|
},
|
||||||
|
isUserRequested: e.data.isUserRequested,
|
||||||
|
toolTelemetry: e.data.toolTelemetry !== undefined ? tryStringify(e.data.toolTelemetry) : undefined,
|
||||||
|
parentToolCallId: e.data.parentToolCallId,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onIdle(() => {
|
||||||
|
this._logService.info(`[Copilot:${sessionId}] Session idle`);
|
||||||
|
this._onDidSessionProgress.fire({ session, type: 'idle' });
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionError(e => {
|
||||||
|
this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`);
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'error',
|
||||||
|
errorType: e.data.errorType,
|
||||||
|
message: e.data.message,
|
||||||
|
stack: e.data.stack,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onUsage(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}`);
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'usage',
|
||||||
|
inputTokens: e.data.inputTokens,
|
||||||
|
outputTokens: e.data.outputTokens,
|
||||||
|
model: e.data.model,
|
||||||
|
cacheReadTokens: e.data.cacheReadTokens,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onReasoningDelta(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`);
|
||||||
|
this._onDidSessionProgress.fire({
|
||||||
|
session,
|
||||||
|
type: 'reasoning',
|
||||||
|
content: e.data.deltaContent,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _subscribeForLogging(): void {
|
||||||
|
const wrapper = this._wrapper;
|
||||||
|
const sessionId = this.sessionId;
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionStart(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Session started: model=${e.data.selectedModel ?? 'default'}, producer=${e.data.producer}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionResume(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Session resumed: eventCount=${e.data.eventCount}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionInfo(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Session info [${e.data.infoType}]: ${e.data.message}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionModelChange(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Model changed: ${e.data.previousModel ?? '(none)'} -> ${e.data.newModel}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionHandoff(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Session handoff: sourceType=${e.data.sourceType}, remoteSessionId=${e.data.remoteSessionId ?? '(none)'}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionTruncation(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Session truncation: removed ${e.data.tokensRemovedDuringTruncation} tokens, ${e.data.messagesRemovedDuringTruncation} messages`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionSnapshotRewind(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Snapshot rewind: upTo=${e.data.upToEventId}, eventsRemoved=${e.data.eventsRemoved}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionShutdown(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Session shutdown: type=${e.data.shutdownType}, premiumRequests=${e.data.totalPremiumRequests}, apiDuration=${e.data.totalApiDurationMs}ms`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionUsageInfo(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Usage info: ${e.data.currentTokens}/${e.data.tokenLimit} tokens, ${e.data.messagesLength} messages`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionCompactionStart(() => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Compaction started`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSessionCompactionComplete(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Compaction complete: success=${e.data.success}, tokensRemoved=${e.data.tokensRemoved ?? '?'}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onUserMessage(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onPendingMessagesModified(() => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Pending messages modified`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onTurnStart(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onIntent(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Intent: ${e.data.intent}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onReasoning(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Reasoning: ${e.data.content.length} chars`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onTurnEnd(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Turn ended: ${e.data.turnId}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onAbort(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Aborted: ${e.data.reason}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onToolUserRequested(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Tool user-requested: ${e.data.toolName} (${e.data.toolCallId})`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onToolPartialResult(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Tool partial result: ${e.data.toolCallId} (${e.data.partialOutput.length} chars)`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onToolProgress(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Tool progress: ${e.data.toolCallId} - ${e.data.progressMessage}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSkillInvoked(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSubagentStarted(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Subagent started: ${e.data.agentName} (${e.data.agentDisplayName})`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSubagentCompleted(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Subagent completed: ${e.data.agentName}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSubagentFailed(e => {
|
||||||
|
this._logService.error(`[Copilot:${sessionId}] Subagent failed: ${e.data.agentName} - ${e.data.error}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSubagentSelected(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Subagent selected: ${e.data.agentName}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onHookStart(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Hook started: ${e.data.hookType} (${e.data.hookInvocationId})`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onHookEnd(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] Hook ended: ${e.data.hookType} (${e.data.hookInvocationId}), success=${e.data.success}`);
|
||||||
|
}));
|
||||||
|
|
||||||
|
this._register(wrapper.onSystemMessage(e => {
|
||||||
|
this._logService.trace(`[Copilot:${sessionId}] System message [${e.data.role}]: ${e.data.content.length} chars`);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cleanup ------------------------------------------------------------
|
||||||
|
|
||||||
|
private _denyPendingPermissions(): void {
|
||||||
|
for (const [, deferred] of this._pendingPermissions) {
|
||||||
|
deferred.complete(false);
|
||||||
|
}
|
||||||
|
this._pendingPermissions.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
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