diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 04a779fc59e..7e4408ceaf1 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -28,9 +28,13 @@ import product from '../../product/common/product.js'; import { IProductService } from '../../product/common/productService.js'; import { localize } from '../../../nls.js'; import { FileService } from '../../files/common/fileService.js'; +import { IFileService } from '../../files/common/files.js'; import { DiskFileSystemProvider } from '../../files/node/diskFileSystemProvider.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 { ISessionDataService } from '../common/sessionDataService.js'; // Entry point for the agent host utility process. // Sets up IPC, logging, and registers agent providers (Copilot). @@ -70,7 +74,12 @@ function startAgentHost(): void { let agentService: AgentService; try { 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) { logService.error('Failed to create AgentService', err); throw err; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 55e78a2c314..d5d8ec5bf56 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,33 +3,20 @@ * 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 { DeferredPromise } from '../../../../base/common/async.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 type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.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 { localize } from '../../../../nls.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 { ToolResultContentType, type IPendingMessage, type IToolResultContent, type PolicyState } from '../../common/state/sessionState.js'; +import { type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; +import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.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}. @@ -43,22 +30,11 @@ export class CopilotAgent extends Disposable implements IAgent { private _client: CopilotClient | undefined; private _clientStarting: Promise | undefined; private _githubToken: string | undefined; - private readonly _sessions = this._register(new DisposableMap()); - /** Tracks active tool invocations so we can produce past-tense messages on completion. Keyed by `sessionId:toolCallId`. */ - private readonly _activeToolCalls = new Map | undefined }>(); - /** Pending permission requests awaiting a renderer-side decision. Keyed by requestId. */ - private readonly _pendingPermissions = new Map }>(); - /** Working directory per session, used when resuming. */ - private readonly _sessionWorkingDirs = new Map(); - /** File edit trackers per session, keyed by raw session ID. */ - private readonly _editTrackers = new Map(); - /** Session database references, keyed by raw session ID. */ - private readonly _sessionDatabases = this._register(new DisposableMap>()); + private readonly _sessions = this._register(new DisposableMap()); constructor( @ILogService private readonly _logService: ILogService, - @IFileService private readonly _fileService: IFileService, - @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); } @@ -203,45 +179,34 @@ export class CopilotAgent extends Disposable implements IAgent { async createSession(config?: IAgentCreateSessionConfig): Promise { this._logService.info(`[Copilot] Creating session... ${config?.model ? `model=${config.model}` : ''}`); const client = await this._ensureClient(); - const raw = await client.createSession({ - model: config?.model, - sessionId: config?.session ? AgentSession.id(config.session) : undefined, - streaming: true, - workingDirectory: config?.workingDirectory, - onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), - hooks: this._createSessionHooks(), - }); - const wrapper = this._trackSession(raw); - const session = AgentSession.uri(this.id, wrapper.sessionId); - if (config?.workingDirectory) { - this._sessionWorkingDirs.set(wrapper.sessionId, config.workingDirectory); - } + const factory: SessionWrapperFactory = async callbacks => { + const raw = await client.createSession({ + model: config?.model, + sessionId: config?.session ? AgentSession.id(config.session) : undefined, + streaming: true, + workingDirectory: config?.workingDirectory, + onPermissionRequest: callbacks.onPermissionRequest, + hooks: callbacks.hooks, + }); + return new CopilotSessionWrapper(raw); + }; + + const agentSession = this._createAgentSession(factory, config?.workingDirectory, config?.session ? AgentSession.id(config.session) : undefined); + await agentSession.initializeSession(); + + const session = agentSession.sessionUri; this._logService.info(`[Copilot] Session created: ${session.toString()}`); return session; } async sendMessage(session: URI, prompt: string, attachments?: IAgentAttachment[]): Promise { 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); - this._logService.info(`[Copilot:${sessionId}] Found session wrapper, calling session.send()...`); - - 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 }); - this._logService.info(`[Copilot:${sessionId}] session.send() returned`); + await entry.send(prompt, attachments); } - setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, queuedMessages: readonly IPendingMessage[]): void { + setPendingMessages(session: URI, steeringMessage: IPendingMessage | undefined, _queuedMessages: readonly IPendingMessage[]): void { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); 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 if (steeringMessage) { - this._logService.info(`[Copilot:${sessionId}] Sending steering message: "${steeringMessage.userMessage.text.substring(0, 100)}"`); - entry.session.send({ - prompt: steeringMessage.userMessage.text, - mode: 'immediate', - }).catch(err => { - this._logService.error(`[Copilot:${sessionId}] Steering message failed`, err); - }); + entry.sendSteering(steeringMessage); } // Queued messages are consumed by the server (AgentSideEffects) @@ -271,33 +230,19 @@ export class CopilotAgent extends Disposable implements IAgent { if (!entry) { return []; } - - 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); + return entry.getMessages(); } async disposeSession(session: URI): Promise { const sessionId = AgentSession.id(session); this._sessions.deleteAndDispose(sessionId); - this._clearToolCallsForSession(sessionId); - this._sessionWorkingDirs.delete(sessionId); - this._sessionDatabases.deleteAndDispose(sessionId); - this._denyPendingPermissionsForSession(sessionId); } async abortSession(session: URI): Promise { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); if (entry) { - this._logService.info(`[Copilot:${sessionId}] Aborting session...`); - this._denyPendingPermissionsForSession(sessionId); - await entry.session.abort(); + await entry.abort(); } } @@ -305,27 +250,22 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = AgentSession.id(session); const entry = this._sessions.get(sessionId); if (entry) { - this._logService.info(`[Copilot:${sessionId}] Changing model to: ${model}`); - await entry.session.setModel(model); + await entry.setModel(model); } } async shutdown(): Promise { this._logService.info('[Copilot] Shutting down...'); this._sessions.clearAndDisposeAll(); - this._activeToolCalls.clear(); - this._sessionWorkingDirs.clear(); - this._denyPendingPermissions(); - this._sessionDatabases.clearAndDisposeAll(); await this._client?.stop(); this._client = undefined; } respondToPermissionRequest(requestId: string, approved: boolean): void { - const entry = this._pendingPermissions.get(requestId); - if (entry) { - this._pendingPermissions.delete(requestId); - entry.deferred.complete(approved); + for (const [, session] of this._sessions) { + if (session.respondToPermissionRequest(requestId, approved)) { + return; + } } } @@ -339,477 +279,47 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ /** - * 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 respondToPermissionRequest. + * Creates a {@link CopilotAgentSession}, registers it in the sessions map, + * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} + * to wire up the SDK session. */ - private async _handlePermissionRequest( - request: { kind: string; toolCallId?: string;[key: string]: unknown }, - invocation: { sessionId: string }, - ): Promise<{ kind: 'approved' | 'denied-interactively-by-user' }> { - const session = AgentSession.uri(this.id, invocation.sessionId); + private _createAgentSession(wrapperFactory: SessionWrapperFactory, workingDirectory: string | undefined, sessionIdOverride?: string): CopilotAgentSession { + const rawId = sessionIdOverride ?? crypto.randomUUID(); + const sessionUri = AgentSession.uri(this.id, rawId); - 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 - if (request.kind === 'read') { - 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; - 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(); - 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' }; + this._sessions.set(rawId, agentSession); + return agentSession; } - /** - * 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 | undefined; - if (toolArgs) { - try { parameters = JSON.parse(toolArgs) as Record; } 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 { + private async _resumeSession(sessionId: string): Promise { this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`); const client = await this._ensureClient(); - const raw = await client.resumeSession(sessionId, { - onPermissionRequest: (request, invocation) => this._handlePermissionRequest(request, invocation), - workingDirectory: this._sessionWorkingDirs.get(sessionId), - hooks: this._createSessionHooks(), - }); - return this._trackSession(raw, sessionId); + + const factory: SessionWrapperFactory = async callbacks => { + const raw = await client.resumeSession(sessionId, { + onPermissionRequest: callbacks.onPermissionRequest, + workingDirectory: undefined, + hooks: callbacks.hooks, + }); + return new CopilotSessionWrapper(raw); + }; + + const agentSession = this._createAgentSession(factory, undefined, sessionId); + await agentSession.initializeSession(); + return agentSession; } override dispose(): void { - this._denyPendingPermissions(); this._client?.stop().catch(() => { /* best-effort */ }); 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); - } - } - } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts new file mode 100644 index 00000000000..edd1b0e401f --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -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; + readonly hooks: { + readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; + readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; + }; +}) => Promise; + +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 | undefined }>(); + /** Pending permission requests awaiting a renderer-side decision. */ + private readonly _pendingPermissions = new Map>(); + /** File edit tracker for this session. */ + private readonly _editTracker: FileEditTracker; + /** Session database reference. */ + private readonly _databaseRef: IReference; + /** 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, + 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 { + 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 { + 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 { + this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); + this._denyPendingPermissions(); + await this._wrapper.session.abort(); + } + + async setModel(model: string): Promise { + 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 { + 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(); + 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 | undefined; + if (toolArgs) { + try { parameters = JSON.parse(toolArgs) as Record; } 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(); + } +} diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts new file mode 100644 index 00000000000..76553dc2785 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -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 void>>(); + + on(eventType: K, handler: TypedSessionEventHandler): () => 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(type: K, data: SessionEventPayload['data']): void { + const event = { type, data, id: 'evt-1', timestamp: new Date().toISOString(), parentId: null } as SessionEventPayload; + 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()); + 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'); + } + }); + }); +});