diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 585d8ec172bbe..ff2c26ab4f262 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -9,7 +9,7 @@ import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oa import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; -import { IProtectedResourceMetadata } from './state/protocol/state.js'; +import { IProtectedResourceMetadata, type IToolDefinition } from './state/protocol/state.js'; import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js'; @@ -215,6 +215,12 @@ export interface IAgentToolStartEvent extends IAgentProgressEventBase { readonly mcpServerName?: string; readonly mcpToolName?: string; readonly parentToolCallId?: string; + /** + * If set, this tool is provided by a client and the identified client + * is responsible for executing it. Maps to `toolClientId` in the + * protocol `session/toolCallStart` action. + */ + readonly toolClientId?: string; } /** A tool has finished executing (`tool.execution_complete`). */ @@ -432,6 +438,28 @@ export interface IAgent { */ setClientCustomizations(clientId: string, customizations: ICustomizationRef[], progress?: (results: ISyncedCustomization[]) => void): Promise; + /** + * Receives client-provided tool definitions to make available in a + * specific session. The agent registers these as custom tools so the + * LLM can call them; execution is routed back to the owning client. + * + * Always called on `activeClientChanged`, even with an empty array, + * to clear a previous client's tools. + * + * @param session The session URI this tool set applies to. + * @param clientId The client that owns these tools. + * @param tools The tool definitions (full replacement). + */ + setClientTools(session: URI, clientId: string, tools: IToolDefinition[]): void; + + /** + * Called when a client completes a client-provided tool call. + * Resolves the tool handler's deferred promise so the SDK can continue. + * + * @param session The session the tool call belongs to. + */ + onClientToolCallComplete(session: URI, toolCallId: string, result: IToolCallResult): void; + /** * Notifies the agent that a customization has been toggled on or off. * The agent MAY restart its client before the next message is sent. diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 16e557ccca268..f0c067d533767 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -7c0c693 +249a721 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 565188d5dfdf4..8be5572c6769d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -66,6 +66,7 @@ export type IClientSessionAction = | ISessionToolCallConfirmedAction | ISessionToolCallCompleteAction | ISessionToolCallResultConfirmedAction + | ISessionToolCallContentChangedAction | ISessionTurnCancelledAction | ISessionTitleChangedAction | ISessionModelChangedAction @@ -92,7 +93,6 @@ export type IServerSessionAction = | ISessionToolCallStartAction | ISessionToolCallDeltaAction | ISessionToolCallReadyAction - | ISessionToolCallContentChangedAction | ISessionTurnCompleteAction | ISessionErrorAction | ISessionUsageAction @@ -152,7 +152,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo [ActionType.SessionToolCallConfirmed]: true, [ActionType.SessionToolCallComplete]: true, [ActionType.SessionToolCallResultConfirmed]: true, - [ActionType.SessionToolCallContentChanged]: false, + [ActionType.SessionToolCallContentChanged]: true, [ActionType.SessionTurnComplete]: false, [ActionType.SessionTurnCancelled]: true, [ActionType.SessionError]: false, diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index f8ee15dcc921a..1d4492dd92e6e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -395,8 +395,14 @@ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBa * use this to display live feedback (e.g. a terminal reference) before the * tool completes. * + * For client-provided tools (where `toolClientId` is set on the tool call state), + * the owning client dispatches this action to stream intermediate content while + * executing. The server SHOULD reject this action if the dispatching client does + * not match `toolClientId`. + * * @category Session Actions * @version 1 + * @clientDispatchable */ export interface ISessionToolCallContentChangedAction extends IToolCallActionBase { type: ActionType.SessionToolCallContentChanged; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index e0381fa1c8521..451a8a5fe64a8 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -1064,9 +1064,6 @@ export type IToolCallState = /** * Describes a tool available in a session, provided by either the server or the active client. * - * This type mirrors the MCP `Tool` type from the Model Context Protocol specification - * (2025-11-25 draft) and will continue to track it. - * * @category Tool Definition Types */ export interface IToolDefinition { diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 5fb6cb15390db..3535218d78c4a 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -115,6 +115,7 @@ export class AgentEventMapper { toolCallId: e.toolCallId, toolName: e.toolName, displayName: e.displayName, + toolClientId: e.toolClientId, _meta: meta, }; const readyAction: IToolCallReadyAction = { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index ffa8529532ef6..1443c922307d2 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -635,8 +635,15 @@ export class AgentSideEffects extends Disposable { } case ActionType.SessionActiveClientChanged: { const agent = this._options.getAgent(action.session); + if (!agent) { + break; + } + // Always forward client tools, even if empty, to clear previous client's tools + const clientId = action.activeClient?.clientId ?? ''; + agent.setClientTools(URI.parse(action.session), clientId, action.activeClient?.tools ?? []); + const refs = action.activeClient?.customizations; - if (!agent?.setClientCustomizations || !refs?.length) { + if (!refs?.length) { break; } // Publish initial "loading" status for all customizations @@ -675,6 +682,17 @@ export class AgentSideEffects extends Disposable { }); break; } + case ActionType.SessionActiveClientToolsChanged: { + const agent = this._options.getAgent(action.session); + if (agent) { + const sessionState = this._stateManager.getSessionState(action.session); + const toolClientId = sessionState?.activeClient?.clientId; + if (toolClientId) { + agent.setClientTools(URI.parse(action.session), toolClientId, action.tools); + } + } + break; + } case ActionType.SessionCustomizationToggled: { const agent = this._options.getAgent(action.session); agent?.setCustomizationEnabled?.(action.uri, action.enabled); @@ -688,6 +706,11 @@ export class AgentSideEffects extends Disposable { this._persistSessionFlag(action.session, 'isDone', action.isDone ? 'true' : ''); break; } + case ActionType.SessionToolCallComplete: { + const agent = this._options.getAgent(action.session); + agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result); + break; + } } } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index bb556b2778217..aed995ef153cc 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4,32 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import { CopilotClient } from '@github/copilot-sdk'; -import * as fs from 'fs/promises'; import { rgPath } from '@vscode/ripgrep'; +import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; +import { equals } from '../../../../base/common/objects.js'; import { basename, delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; import { IParsedPlugin, parsePlugin } from '../../../agentPlugins/common/pluginParsers.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 { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentHostSessionConfigBranchNameHintKey, AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; -import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; -import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type ISessionInputAnswer, type IPendingMessage, type PolicyState } from '../../common/state/sessionState.js'; -import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSession.js'; -import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; -import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; +import type { IResolveSessionConfigResult, ISessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; +import { IProtectedResourceMetadata, type IToolDefinition } from '../../common/state/protocol/state.js'; +import { CustomizationStatus, ICustomizationRef, SessionInputResponseKind, type IPendingMessage, type ISessionInputAnswer, type IToolCallResult, type PolicyState } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../agentHostGitService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { CopilotAgentSession, SessionWrapperFactory, type IActiveClientSnapshot } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; +import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; +import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { createShellTools, ShellManager } from './copilotShellTools.js'; interface ICreatedWorktree { @@ -67,6 +69,8 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _sessionSequencer = new SequencerByKey(); private _shutdownPromise: Promise | undefined; private readonly _plugins: PluginController; + /** Per-session active client state for tools + plugin snapshot tracking. */ + private readonly _activeClients = new ResourceMap(); constructor( @ILogService private readonly _logService: ILogService, @@ -233,7 +237,6 @@ 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 parsedPlugins = await this._plugins.getAppliedPlugins(); // When forking, use the SDK's sessions.fork RPC. if (config?.fork) { @@ -290,7 +293,9 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = config?.session ? AgentSession.id(config.session) : generateUuid(); const sessionUri = AgentSession.uri(this.id, sessionId); const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri); - const sessionConfig = this._buildSessionConfig(parsedPlugins, shellManager); + const activeClient = this._activeClients.get(sessionUri); + const snapshot = activeClient ? await activeClient.snapshot() : undefined; + const sessionConfig = this._buildSessionConfig(snapshot, shellManager); const workingDirectory = await this._resolveSessionWorkingDirectory(config, sessionId); const factory: SessionWrapperFactory = async callbacks => { @@ -306,8 +311,7 @@ export class CopilotAgent extends Disposable implements IAgent { let agentSession: CopilotAgentSession; try { - agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager); - this._plugins.setAppliedPlugins(agentSession, parsedPlugins); + agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager, snapshot); await agentSession.initializeSession(); } catch (error) { await this._removeCreatedWorktree(sessionId); @@ -406,6 +410,17 @@ export class CopilotAgent extends Disposable implements IAgent { return this._plugins.sync(clientId, customizations, progress); } + setClientTools(session: URI, clientId: string, tools: IToolDefinition[]): void { + const activeClient = this._getOrCreateActiveClient(session); + activeClient.updateTools(clientId, tools); + this._logService.info(`[Copilot:${AgentSession.id(session)}] Client tools updated: ${tools.map(t => t.name).join(', ') || '(none)'}`); + } + + onClientToolCallComplete(session: URI, toolCallId: string, result: IToolCallResult): void { + const entry = this._sessions.get(AgentSession.id(session)); + entry?.handleClientToolCallComplete(toolCallId, result); + } + setCustomizationEnabled(uri: string, enabled: boolean): void { this._plugins.setEnabled(uri, enabled); } @@ -414,11 +429,12 @@ export class CopilotAgent extends Disposable implements IAgent { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { - // If plugin config changed, dispose this session so it gets resumed - // with the updated plugin primitives. + // If the active client's config changed (tools or plugins), + // dispose this session so it gets resumed with the updated config. let entry = this._sessions.get(sessionId); - if (entry && await this._plugins.needsSessionRefresh(entry)) { - this._logService.info(`[Copilot:${sessionId}] Plugin config changed, refreshing session`); + const activeClient = this._activeClients.get(session); + if (entry && activeClient && await activeClient.isOutdated(entry.appliedSnapshot)) { + this._logService.info(`[Copilot:${sessionId}] Session config changed, refreshing session`); this._sessions.deleteAndDispose(sessionId); entry = undefined; } @@ -549,22 +565,34 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ + private _getOrCreateActiveClient(session: URI): ActiveClient { + let client = this._activeClients.get(session); + if (!client) { + client = new ActiveClient(() => this._plugins.getAppliedPlugins()); + this._activeClients.set(session, client); + } + return client; + } + /** * 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 _createAgentSession(wrapperFactory: SessionWrapperFactory, workingDirectory: URI | undefined, sessionId: string, shellManager: ShellManager): CopilotAgentSession { + private _createAgentSession(wrapperFactory: SessionWrapperFactory, workingDirectory: URI | undefined, sessionId: string, shellManager: ShellManager, snapshot?: IActiveClientSnapshot): CopilotAgentSession { const sessionUri = AgentSession.uri(this.id, sessionId); const agentSession = this._instantiationService.createInstance( CopilotAgentSession, - sessionUri, - sessionId, - workingDirectory, - this._onDidSessionProgress, - wrapperFactory, - shellManager, + { + sessionUri, + rawSessionId: sessionId, + workingDirectory, + onDidSessionProgress: this._onDidSessionProgress, + wrapperFactory, + shellManager, + clientSnapshot: snapshot, + }, ); this._sessions.set(sessionId, agentSession); @@ -592,19 +620,20 @@ export class CopilotAgent extends Disposable implements IAgent { * session's permission/hook callbacks, so it can be called lazily * inside the {@link SessionWrapperFactory}. */ - private _buildSessionConfig(parsedPlugins: readonly IParsedPlugin[], shellManager: ShellManager) { + private _buildSessionConfig(snapshot: IActiveClientSnapshot | undefined, shellManager: ShellManager) { const shellTools = createShellTools(shellManager, this._terminalManager, this._logService); + const plugins = snapshot?.plugins ?? []; return async (callbacks: Parameters[0]) => { - const customAgents = await toSdkCustomAgents(parsedPlugins.flatMap(p => p.agents), this._fileService); + const customAgents = await toSdkCustomAgents(plugins.flatMap(p => p.agents), this._fileService); return { onPermissionRequest: callbacks.onPermissionRequest, onUserInputRequest: callbacks.onUserInputRequest, - hooks: toSdkHooks(parsedPlugins.flatMap(p => p.hooks), callbacks.hooks), - mcpServers: toSdkMcpServers(parsedPlugins.flatMap(p => p.mcpServers)), + hooks: toSdkHooks(plugins.flatMap(p => p.hooks), callbacks.hooks), + mcpServers: toSdkMcpServers(plugins.flatMap(p => p.mcpServers)), customAgents, - skillDirectories: toSdkSkillDirectories(parsedPlugins.flatMap(p => p.skills)), - tools: shellTools, + skillDirectories: toSdkSkillDirectories(plugins.flatMap(p => p.skills)), + tools: [...shellTools, ...callbacks.clientTools], }; }; } @@ -612,9 +641,10 @@ export class CopilotAgent extends Disposable implements IAgent { private async _resumeSession(sessionId: string): Promise { this._logService.info(`[Copilot:${sessionId}] Session not in memory, resuming...`); const client = await this._ensureClient(); - const parsedPlugins = await this._plugins.getAppliedPlugins(); const sessionUri = AgentSession.uri(this.id, sessionId); + const activeClient = this._activeClients.get(sessionUri); + const snapshot = activeClient ? await activeClient.snapshot() : undefined; const storedMetadata = await this._readSessionMetadata(sessionUri); const sessionMetadata = await client.getSessionMetadata(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] getSessionMetadata failed`, err); @@ -622,7 +652,7 @@ export class CopilotAgent extends Disposable implements IAgent { }); const workingDirectory = typeof sessionMetadata?.context?.cwd === 'string' ? URI.file(sessionMetadata.context.cwd) : storedMetadata.workingDirectory; const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri); - const sessionConfig = this._buildSessionConfig(parsedPlugins, shellManager); + const sessionConfig = this._buildSessionConfig(snapshot, shellManager); const factory: SessionWrapperFactory = async callbacks => { const config = await sessionConfig(callbacks); @@ -653,8 +683,7 @@ export class CopilotAgent extends Disposable implements IAgent { } }; - const agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager); - this._plugins.setAppliedPlugins(agentSession, parsedPlugins); + const agentSession = this._createAgentSession(factory, workingDirectory, sessionId, shellManager, snapshot); await agentSession.initializeSession(); return agentSession; @@ -820,9 +849,6 @@ class PluginController { private readonly _enablement = new Map(); private _lastSynced: Promise<{ synced: ISyncedCustomization[]; parsed: IParsedPlugin[] }> = Promise.resolve({ synced: [], parsed: [] }); - /** Parsed plugin contents from the most recently applied sync. */ - private _appliedParsed = new WeakMap(); - constructor( @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, @ILogService private readonly _logService: ILogService, @@ -830,29 +856,13 @@ class PluginController { ) { } /** - * Returns true if the plugin configuration has changed since the last - * time sessions were created/resumed. Used by {@link CopilotAgent.sendMessage} - * to decide whether a session needs to be refreshed. - */ - public async needsSessionRefresh(session: CopilotAgentSession): Promise { - const { parsed } = await this._lastSynced; - return !parsedPluginsEqual(this._appliedParsed.get(session) || [], parsed); - } - - /** - * Returns the current parsed plugins filtered by enablement, - * then marks them as applied so {@link needsSessionRefresh} returns - * false until the next change. + * Returns the current parsed plugins, awaiting any pending sync. */ public async getAppliedPlugins(): Promise { const { parsed } = await this._lastSynced; return parsed; } - public setAppliedPlugins(session: CopilotAgentSession, plugins: readonly IParsedPlugin[]) { - this._appliedParsed.set(session, plugins); - } - public setEnabled(pluginProtocolUri: string, enabled: boolean) { this._enablement.set(pluginProtocolUri, enabled); } @@ -891,3 +901,49 @@ class PluginController { return process.env['HOME'] ?? process.env['USERPROFILE'] ?? ''; } } + +/** + * Tracks per-session active client contributions (tools and plugins). + * The {@link snapshot} captures the state at session creation time, and + * {@link isOutdated} detects when the session needs to be refreshed. + */ +class ActiveClient { + private _tools: readonly IToolDefinition[] = []; + private _clientId = ''; + + constructor( + /** Resolves the current set of applied plugins. May block while a sync is in progress. */ + private readonly _resolvePlugins: () => Promise, + ) { } + + updateTools(clientId: string, tools: readonly IToolDefinition[]): void { + this._clientId = clientId; + this._tools = tools; + } + + async snapshot(): Promise { + return { clientId: this._clientId, tools: this._tools, plugins: await this._resolvePlugins() }; + } + + async isOutdated(snap: IActiveClientSnapshot): Promise { + const plugins = await this._resolvePlugins(); + if (!parsedPluginsEqual(snap.plugins, plugins)) { + return true; + } + if (snap.tools.length !== this._tools.length) { + return true; + } + // Compare tool definitions by name, description, and schema — + // not just names — so schema/description changes trigger a refresh. + const snapByName = new Map(snap.tools.map(t => [t.name, t])); + for (const tool of this._tools) { + const prev = snapByName.get(tool.name); + if (!prev + || prev.description !== tool.description + || !equals(prev.inputSchema, tool.inputSchema)) { + return true; + } + } + return false; + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 79e9e01421790..093b615d363f5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { PermissionRequest, PermissionRequestResult } from '@github/copilot-sdk'; +import type { PermissionRequest, PermissionRequestResult, Tool, ToolResultObject } 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'; @@ -15,12 +15,24 @@ import { ILogService } from '../../../log/common/log.js'; import { localize } from '../../../../nls.js'; import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; -import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type IPendingMessage, type IToolResultContent } from '../../common/state/sessionState.js'; +import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type ISessionInputAnswer, type ISessionInputRequest, type IPendingMessage, type IToolCallResult, type IToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; import type { ShellManager } from './copilotShellTools.js'; +import type { IToolDefinition } from '../../common/state/protocol/state.js'; +import type { IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; + +/** + * Immutable snapshot of the active client's contributions at session creation + * time. Used to detect when the session needs to be refreshed. + */ +export interface IActiveClientSnapshot { + readonly clientId: string; + readonly tools: readonly IToolDefinition[]; + readonly plugins: readonly IParsedPlugin[]; +} /** * Factory function that produces a {@link CopilotSessionWrapper}. @@ -38,6 +50,8 @@ export type SessionWrapperFactory = (callbacks: { readonly onPreToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; readonly onPostToolUse: (input: { toolName: string; toolArgs: unknown }) => Promise; }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly clientTools: Tool[]; }) => Promise; /** Matches the SDK's `UserInputRequest` which is not re-exported from the package entry point. */ @@ -139,6 +153,20 @@ function getPermissionDisplay(request: { kind: string;[key: string]: unknown }): } } +/** + * Options for constructing a {@link CopilotAgentSession}. + */ +export interface ICopilotAgentSessionOptions { + readonly sessionUri: URI; + readonly rawSessionId: string; + readonly workingDirectory: URI | undefined; + readonly onDidSessionProgress: Emitter; + readonly wrapperFactory: SessionWrapperFactory; + readonly shellManager: ShellManager | undefined; + /** Snapshot of the active client's tools and plugins at session creation time. */ + readonly clientSnapshot?: IActiveClientSnapshot; +} + /** * Encapsulates a single Copilot SDK session and all its associated bookkeeping. * @@ -166,31 +194,114 @@ export class CopilotAgentSession extends Disposable { private _wrapper!: CopilotSessionWrapper; private readonly _workingDirectory: URI | undefined; + /** Snapshot captured at session creation for refresh detection. */ + private readonly _appliedSnapshot: IActiveClientSnapshot; + /** Tool names that are client-provided, derived from snapshot. */ + private readonly _clientToolNames: ReadonlySet; + /** Deferred promises for pending client tool calls, keyed by toolCallId. */ + private readonly _pendingClientToolCalls = new Map>(); + + private readonly _onDidSessionProgress: Emitter; + private readonly _wrapperFactory: SessionWrapperFactory; + private readonly _shellManager: ShellManager | undefined; constructor( - sessionUri: URI, - rawSessionId: string, - workingDirectory: URI | undefined, - private readonly _onDidSessionProgress: Emitter, - private readonly _wrapperFactory: SessionWrapperFactory, - private readonly _shellManager: ShellManager | undefined, + options: ICopilotAgentSessionOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @ISessionDataService sessionDataService: ISessionDataService, ) { super(); - this.sessionId = rawSessionId; - this.sessionUri = sessionUri; - this._workingDirectory = workingDirectory; + this.sessionId = options.rawSessionId; + this.sessionUri = options.sessionUri; + this._workingDirectory = options.workingDirectory; + this._onDidSessionProgress = options.onDidSessionProgress; + this._wrapperFactory = options.wrapperFactory; + this._shellManager = options.shellManager; + + this._appliedSnapshot = options.clientSnapshot ?? { clientId: '', tools: [], plugins: [] }; + this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); - this._databaseRef = sessionDataService.openDatabase(sessionUri); + this._databaseRef = sessionDataService.openDatabase(options.sessionUri); this._register(toDisposable(() => this._databaseRef.dispose())); - this._editTracker = this._instantiationService.createInstance(FileEditTracker, sessionUri.toString(), this._databaseRef.object); + this._editTracker = this._instantiationService.createInstance(FileEditTracker, options.sessionUri.toString(), this._databaseRef.object); this._register(toDisposable(() => this._denyPendingPermissions())); this._register(toDisposable(() => this._shellManager?.dispose())); this._register(toDisposable(() => this._cancelPendingUserInputs())); + this._register(toDisposable(() => this._cancelPendingClientToolCalls())); + } + + /** + * The snapshot of client contributions captured when this session was + * created. Used by the agent to detect when the session is stale. + */ + get appliedSnapshot(): IActiveClientSnapshot { + return this._appliedSnapshot; + } + + /** + * Creates SDK {@link Tool} objects for the client-provided tools in the + * applied snapshot. The handler creates a {@link DeferredPromise} and waits + * for the client to dispatch `session/toolCallComplete`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createClientSdkTools(): Tool[] { + const tools = this._appliedSnapshot.tools; + if (tools.length === 0) { + return []; + } + return tools.map(def => ({ + name: def.name, + description: def.description ?? '', + parameters: def.inputSchema ?? { type: 'object' as const, properties: {} }, + handler: async (_args: unknown, invocation: { toolCallId: string }) => { + const deferred = new DeferredPromise(); + this._pendingClientToolCalls.set(invocation.toolCallId, deferred); + return deferred.p; + }, + })); + } + + /** + * Resolves a pending client tool call. Returns `true` if the + * toolCallId was found and handled. + */ + handleClientToolCallComplete(toolCallId: string, result: IToolCallResult): boolean { + const deferred = this._pendingClientToolCalls.get(toolCallId); + if (!deferred) { + return false; + } + this._pendingClientToolCalls.delete(toolCallId); + + const textContent = result.content + ?.filter(c => c.type === 'text') + .map(c => (c as { text: string }).text) + .join('\n') ?? ''; + + const binaryResults = result.content + ?.filter(c => c.type === 'embeddedResource') + .map(c => { + const embedded = c as { data: string; contentType: string }; + return { data: embedded.data, mimeType: embedded.contentType, type: embedded.contentType }; + }); + + if (result.success) { + deferred.complete({ + textResultForLlm: textContent, + resultType: 'success', + binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, + }); + } else { + deferred.complete({ + textResultForLlm: textContent || result.error?.message || 'Tool call failed', + resultType: 'failure', + error: result.error?.message, + binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, + }); + } + return true; } /** @@ -202,6 +313,7 @@ export class CopilotAgentSession extends Disposable { this._wrapper = this._register(await this._wrapperFactory({ onPermissionRequest: request => this.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => this.handleUserInputRequest(request, invocation), + clientTools: this.createClientSdkTools(), hooks: { onPreToolUse: async input => { if (isEditTool(input.toolName)) { @@ -516,6 +628,7 @@ export class CopilotAgentSession extends Disposable { mcpServerName: e.data.mcpServerName, mcpToolName: e.data.mcpToolName, parentToolCallId: e.data.parentToolCallId, + toolClientId: this._clientToolNames.has(e.data.toolName) ? this._appliedSnapshot.clientId : undefined, }); })); @@ -808,4 +921,11 @@ export class CopilotAgentSession extends Disposable { } this._pendingUserInputs.clear(); } + + private _cancelPendingClientToolCalls(): void { + for (const [, deferred] of this._pendingClientToolCalls) { + deferred.complete({ textResultForLlm: 'Tool call cancelled: session ended', resultType: 'failure', error: 'Session ended' }); + } + this._pendingClientToolCalls.clear(); + } } diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 3b02e51cad297..7e316775ce76e 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -87,12 +87,14 @@ async function createAgentSession(disposables: DisposableStore, options?: { work const session = disposables.add(instantiationService.createInstance( CopilotAgentSession, - sessionUri, - 'test-session-1', - options?.workingDirectory, - progressEmitter, - factory, - undefined, // shellManager + { + sessionUri, + rawSessionId: 'test-session-1', + workingDirectory: options?.workingDirectory, + onDidSessionProgress: progressEmitter, + wrapperFactory: factory, + shellManager: undefined, + }, )); await session.initializeSession(); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 36a001ae44888..f546e8399e552 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -146,6 +146,10 @@ export class MockAgent implements IAgent { this.setCustomizationEnabledCalls.push({ uri, enabled }); } + setClientTools(): void { } + + onClientToolCallComplete(): void { } + async shutdown(): Promise { } fireProgress(event: IAgentProgressEvent): void { @@ -460,6 +464,10 @@ export class ScriptedMockAgent implements IAgent { } + setClientTools(): void { } + + onClientToolCallComplete(): void { } + async getSessionMessages(session: URI): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent)[]> { if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { return this._preExistingMessages; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 98e3214882ee1..236840ad85a08 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -4,23 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import { Throttler } from '../../../../../../base/common/async.js'; +import { encodeBase64 } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { BugIndicatingError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; -import { autorun, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { observableConfigValue } from '../../../../../../platform/observable/common/platformObservableUtils.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { AgentHostSessionConfigBranchNameHintKey, AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { ICustomizationRef, TerminalClaimKind, type IProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ICustomizationRef, TerminalClaimKind, ToolResultContentType, type IProtectedResourceMetadata, type IToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ISessionTurnStartedAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IRootState, type IResponsePart, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, buildSubagentSessionUri, getToolFileEdits, getToolSubagentContent, PendingMessageKind, ResponsePartKind, SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ICompletedToolCall, type IMessageAttachment, type IRootState, type IResponsePart, type ISessionInputAnswer, type ISessionInputRequest, type ISessionState, type IToolCallRunningState, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -29,12 +32,12 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { ChatRequestQueueKind, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatTerminalToolInvocationData, type IChatMultiSelectAnswer, type IChatSingleSelectAnswer } from '../../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; @@ -58,6 +61,8 @@ import { getToolKind } from '../../../../../../platform/agentHost/common/state/s interface ITurnProcessingContext { readonly turnId: string; readonly backendSession: URI; + /** The UI session resource (agent-host-copilot:/...) for tool invocation context. */ + readonly sessionResource: URI; readonly activeToolInvocations: Map; readonly lastEmittedLengths: Map; readonly progress: (parts: IChatProgress[]) => void; @@ -290,6 +295,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** Active session subscriptions, keyed by backend session URI string. */ private readonly _sessionSubscriptions = new Map>>(); + /** Observable of client-provided tools filtered by the allowlist and `when` clauses. */ + private readonly _clientToolsObs: IObservable; + /** Set of tool call IDs for client tool calls currently being executed. */ + private readonly _executingClientToolCalls = new Set(); + constructor( config: IAgentHostSessionHandlerConfig, @IChatAgentService private readonly _chatAgentService: IChatAgentService, @@ -302,10 +312,43 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, + @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); this._config = config; + // Build an observable of client tools: all tools matching the + // allowlist setting, filtered by `when` clauses via observeTools. + // We pass `undefined` for the model since agent host sessions use + // server-side model selection and client tools should be available + // regardless of which model is active. + const allToolsObs = this._toolsService.observeTools(undefined); + const allowlistObs = observableConfigValue(ChatConfiguration.AgentHostClientTools, [], this._configurationService); + this._clientToolsObs = derived(reader => { + const allowlist = new Set(allowlistObs.read(reader)); + const allTools = allToolsObs.read(reader); + return allTools.filter(t => t.toolReferenceName !== undefined && allowlist.has(t.toolReferenceName)); + }); + + // When the client tools set changes, dispatch + // activeClientToolsChanged for all active sessions owned by this + // client so the server sees the updated tool list. + this._register(autorun(reader => { + const tools = this._clientToolsObs.read(reader); + const defs = tools.map(toolDataToDefinition); + for (const [, backendSession] of this._sessionToBackend) { + const state = this._getSessionState(backendSession.toString()); + if (state?.activeClient?.clientId === this._config.connection.clientId) { + this._dispatchAction({ + type: ActionType.SessionActiveClientToolsChanged, + session: backendSession.toString(), + tools: defs, + }); + } + } + })); + // When the user clicks "Continue in Background" on an AHP terminal // tool, narrow the terminal claim so the server-side tool handler // can detect it and return early. @@ -646,7 +689,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /** * Dispatches `session/activeClientChanged` to claim the active client - * role for this session and publish the current customizations. + * role for this session and publish the current customizations and + * client-provided tools. */ private _dispatchActiveClient(backendSession: URI, customizations: ICustomizationRef[]): void { this._dispatchAction({ @@ -654,7 +698,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC session: backendSession.toString(), activeClient: { clientId: this._config.connection.clientId, - tools: [], + tools: this._clientToolsObs.get().map(toolDataToDefinition), customizations, }, }); @@ -786,6 +830,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const ctx: ITurnProcessingContext = { turnId, backendSession, + sessionResource: chatSession.sessionResource, activeToolInvocations, lastEmittedLengths, progress, @@ -949,6 +994,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const ctx: ITurnProcessingContext = { turnId, backendSession: session, + sessionResource: request.sessionResource, activeToolInvocations, lastEmittedLengths, progress, @@ -1070,6 +1116,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC : new MarkdownString(tc.invocationMessage.markdown); this._reviveTerminalIfNeeded(existing, tc, ctx.backendSession); updateRunningToolSpecificData(existing, tc); + + // If this is a client-provided tool call owned by us, execute it. + if (tc.status === ToolCallStatus.Running + && tc.toolClientId === this._config.connection.clientId + && !this._executingClientToolCalls.has(toolCallId)) { + this._executeClientToolCall(tc, ctx); + } } // Finalize terminal-state tools @@ -1084,6 +1137,103 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return { invocation: existing, fileEdits }; } + // ---- Client tool execution ---------------------------------------------- + + /** + * Executes a client-provided tool call. Looks up the tool in the local + * tool service, invokes it, and dispatches the result back to the server + * via `session/toolCallComplete`. + */ + private _executeClientToolCall(tc: IToolCallRunningState, ctx: ITurnProcessingContext): void { + const toolCallId = tc.toolCallId; + this._executingClientToolCalls.add(toolCallId); + + const toolData = this._toolsService.getToolByName(tc.toolName); + if (!toolData) { + this._logService.warn(`[AgentHost] Client tool call for unknown tool: ${tc.toolName}`); + this._dispatchAction({ + type: ActionType.SessionToolCallComplete, + session: ctx.backendSession.toString(), + turnId: ctx.turnId, + toolCallId, + result: { + success: false, + pastTenseMessage: `Tool "${tc.toolName}" is not available`, + error: { message: `Tool "${tc.toolName}" is not available on this client` }, + }, + }); + this._executingClientToolCalls.delete(toolCallId); + return; + } + + // Parse tool input parameters + let parameters: Record = {}; + if (tc.toolInput) { + try { + const parsed: unknown = JSON.parse(tc.toolInput); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('expected JSON object'); + } + parameters = parsed as Record; + } catch { + this._logService.warn(`[AgentHost] Failed to parse tool input for ${tc.toolName}`); + this._dispatchAction({ + type: ActionType.SessionToolCallComplete, + session: ctx.backendSession.toString(), + turnId: ctx.turnId, + toolCallId, + result: { + success: false, + pastTenseMessage: `Failed to execute ${tc.toolName}`, + error: { message: `Invalid tool input for "${tc.toolName}": expected JSON object parameters` }, + }, + }); + this._executingClientToolCalls.delete(toolCallId); + return; + } + } + + const invocation: IToolInvocation = { + callId: toolCallId, + toolId: toolData.id, + parameters, + context: { sessionResource: ctx.sessionResource }, + }; + + const noOpCountTokens = async () => 0; + + this._logService.info(`[AgentHost] Executing client tool: ${tc.toolName} (callId=${toolCallId})`); + + this._toolsService.invokeTool( + invocation, + noOpCountTokens, + ctx.cancellationToken, + ).then(result => { + this._dispatchAction({ + type: ActionType.SessionToolCallComplete, + session: ctx.backendSession.toString(), + turnId: ctx.turnId, + toolCallId, + result: toolResultToProtocol(result, tc.toolName), + }); + }).catch(err => { + this._logService.warn(`[AgentHost] Client tool invocation failed: ${tc.toolName}`, err); + this._dispatchAction({ + type: ActionType.SessionToolCallComplete, + session: ctx.backendSession.toString(), + turnId: ctx.turnId, + toolCallId, + result: { + success: false, + pastTenseMessage: `Failed to execute ${tc.toolName}`, + error: { message: String(err?.message ?? err) }, + }, + }); + }).finally(() => { + this._executingClientToolCalls.delete(toolCallId); + }); + } + /** * Detects terminal content in a tool call and creates a local terminal * instance backed by the agent host connection. Updates the invocation's @@ -1638,6 +1788,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const ctx: ITurnProcessingContext = { turnId, backendSession, + sessionResource: chatSession.sessionResource, activeToolInvocations, lastEmittedLengths, progress: parts => chatSession.appendProgress(parts), @@ -2035,3 +2186,60 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC super.dispose(); } } + +// ============================================================================= +// Client-provided tool helpers +// ============================================================================= + +/** + * Converts an internal {@link IToolData} to a protocol {@link IToolDefinition}. + */ +export function toolDataToDefinition(tool: IToolData): IToolDefinition { + return { + name: tool.toolReferenceName ?? tool.id, + title: tool.displayName, + description: tool.modelDescription, + inputSchema: tool.inputSchema?.type === 'object' + ? tool.inputSchema as IToolDefinition['inputSchema'] + : undefined, + }; +} + +/** + * Converts an internal {@link IToolResult} to a protocol + * {@link import('../../../../../../platform/agentHost/common/state/protocol/state.js').IToolCallResult}. + */ +export function toolResultToProtocol(result: IToolResult, toolName: string): { + success: boolean; + pastTenseMessage: string; + content?: ({ type: ToolResultContentType.Text; text: string } | { type: ToolResultContentType.EmbeddedResource; data: string; contentType: string })[]; + error?: { message: string }; +} { + const isError = !!result.toolResultError; + const pastTense = typeof result.toolResultMessage === 'string' + ? result.toolResultMessage + : result.toolResultMessage?.value + ?? (isError ? `${toolName} failed` : `Ran ${toolName}`); + + const content: ({ type: ToolResultContentType.Text; text: string } | { type: ToolResultContentType.EmbeddedResource; data: string; contentType: string })[] = []; + for (const part of result.content) { + if (part.kind === 'text') { + content.push({ type: ToolResultContentType.Text, text: part.value }); + } else if (part.kind === 'data') { + content.push({ + type: ToolResultContentType.EmbeddedResource, + data: encodeBase64(part.value.data), + contentType: part.value.mimeType, + }); + } + } + + return { + success: !isError, + pastTenseMessage: pastTense, + content: content.length > 0 ? content : undefined, + error: isError + ? { message: typeof result.toolResultError === 'string' ? result.toolResultError : `${toolName} encountered an error` } + : undefined, + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index c6e3bc569679f..0764147143c8c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -886,6 +886,14 @@ configurationRegistry.registerConfiguration({ tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, + [ChatConfiguration.AgentHostClientTools]: { + type: 'array', + items: { type: 'string' }, + description: nls.localize('chat.agentHost.clientTools', "Tool reference names to expose as client-provided tools in agent host sessions."), + default: ['runTask', 'getTaskOutput', 'problems', 'runTests'], + tags: ['experimental', 'advanced'], + included: product.quality !== 'stable', + }, [ChatConfiguration.ToolConfirmationCarousel]: { type: 'boolean', description: nls.localize('chat.tools.confirmationCarousel', "When enabled, multiple tool confirmations are batched into a carousel above the input."), diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 571702c6cfc52..37735f9b0586a 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -67,6 +67,7 @@ export enum ChatConfiguration { ArtifactsRulesByMemoryFilePath = 'chat.artifacts.rules.byMemoryFilePath', ToolConfirmationCarousel = 'chat.tools.confirmationCarousel.enabled', DefaultNewSessionMode = 'chat.newSession.defaultMode', + AgentHostClientTools = 'chat.agentHost.clientTools', } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 3f4171a10b79e..aef15e151943f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -48,6 +48,7 @@ import { IAgentSubscription } from '../../../../../../platform/agentHost/common/ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; // ---- Mock agent host service ------------------------------------------------ @@ -285,7 +286,15 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv deltaLanguageModelChatProviderDescriptors: () => { }, registerLanguageModelProvider: () => toDisposable(() => { }), }); - instantiationService.stub(IConfigurationService, { getValue: () => true }); + instantiationService.stub(IConfigurationService, { + getValue: (...args: unknown[]) => { + if (args[0] === 'chat.agentHost.clientTools') { + return []; + } + return true; + }, + onDidChangeConfiguration: Event.None, + }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null }); instantiationService.stub(IChatEditingService, { @@ -318,6 +327,14 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv registerResolver: () => toDisposable(() => { }), resolve: sessionResource => workingDirectoryResolver?.resolve(sessionResource), }); + instantiationService.stub(ILanguageModelToolsService, { + observeTools: () => observableValue('tools', []), + onDidChangeTools: Event.None, + getToolByName: () => undefined, + invokeTool: async () => ({ content: [] }), + onDidPrepareToolCallBecomeUnresponsive: Event.None, + onDidInvokeTool: Event.None, + }); return { instantiationService, agentHostService, chatAgentService }; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts new file mode 100644 index 0000000000000..b6b7735a50c7f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -0,0 +1,514 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { isSessionAction, type IActionEnvelope, type INotification, type ISessionAction, type ITerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type ISessionState, type ISessionSummary, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; +import { ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { IChatAgentService } from '../../../common/participants/chatAgents.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; +import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; +import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { AgentHostSessionHandler, toolDataToDefinition, toolResultToProtocol } from '../../../browser/agentSessions/agentHost/agentHostSessionHandler.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; +import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; +import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; +import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; +import { ILanguageModelToolsService, IToolData, IToolResult, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; +import { IOutputService } from '../../../../../services/output/common/output.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; + +// ============================================================================= +// Unit tests for toolDataToDefinition and toolResultToProtocol +// ============================================================================= + +suite('AgentHostClientTools', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + ensureNoDisposablesAreLeakedInTestSuite(); + + // ── toolDataToDefinition ───────────────────────────────────────────── + + suite('toolDataToDefinition', () => { + + test('maps toolReferenceName, displayName, modelDescription, and inputSchema', () => { + const tool: IToolData = { + id: 'vscode.runTests', + toolReferenceName: 'runTests', + displayName: 'Run Tests', + modelDescription: 'Runs unit tests in files', + userDescription: 'Run tests', + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + files: { type: 'array', items: { type: 'string' } }, + }, + }, + }; + + const def = toolDataToDefinition(tool); + + assert.deepStrictEqual(def, { + name: 'runTests', + title: 'Run Tests', + description: 'Runs unit tests in files', + inputSchema: { + type: 'object', + properties: { + files: { type: 'array', items: { type: 'string' } }, + }, + }, + }); + }); + + test('falls back to id when toolReferenceName is undefined', () => { + const tool: IToolData = { + id: 'vscode.runTests', + displayName: 'Run Tests', + modelDescription: 'Runs unit tests', + source: ToolDataSource.Internal, + }; + + const def = toolDataToDefinition(tool); + assert.strictEqual(def.name, 'vscode.runTests'); + }); + + test('omits inputSchema when schema type is not object', () => { + const tool: IToolData = { + id: 'myTool', + toolReferenceName: 'myTool', + displayName: 'My Tool', + modelDescription: 'A tool', + source: ToolDataSource.Internal, + inputSchema: { type: 'string' }, + }; + + const def = toolDataToDefinition(tool); + assert.strictEqual(def.inputSchema, undefined); + }); + + test('omits inputSchema when not provided', () => { + const tool: IToolData = { + id: 'myTool', + toolReferenceName: 'myTool', + displayName: 'My Tool', + modelDescription: 'A tool', + source: ToolDataSource.Internal, + }; + + const def = toolDataToDefinition(tool); + assert.strictEqual(def.inputSchema, undefined); + }); + }); + + // ── toolResultToProtocol ───────────────────────────────────────────── + + suite('toolResultToProtocol', () => { + + test('converts successful result with text content', () => { + const result: IToolResult = { + content: [ + { kind: 'text', value: 'All 5 tests passed' }, + ], + toolResultMessage: 'Ran 5 tests', + }; + + const proto = toolResultToProtocol(result, 'runTests'); + + assert.deepStrictEqual(proto, { + success: true, + pastTenseMessage: 'Ran 5 tests', + content: [{ type: ToolResultContentType.Text, text: 'All 5 tests passed' }], + error: undefined, + }); + }); + + test('converts failed result with error', () => { + const result: IToolResult = { + content: [{ kind: 'text', value: 'Build failed' }], + toolResultError: 'Compilation error in file.ts', + }; + + const proto = toolResultToProtocol(result, 'runTask'); + + assert.deepStrictEqual(proto, { + success: false, + pastTenseMessage: 'runTask failed', + content: [{ type: ToolResultContentType.Text, text: 'Build failed' }], + error: { message: 'Compilation error in file.ts' }, + }); + }); + + test('uses default past tense message when toolResultMessage is absent', () => { + const result: IToolResult = { + content: [{ kind: 'text', value: 'done' }], + }; + + const proto = toolResultToProtocol(result, 'myTool'); + assert.strictEqual(proto.pastTenseMessage, 'Ran myTool'); + }); + + test('converts text and data content parts', () => { + const binaryData = VSBuffer.fromString('hello binary'); + const result: IToolResult = { + content: [ + { kind: 'text', value: 'hello' }, + { kind: 'data', value: { mimeType: 'image/png', data: binaryData } }, + { kind: 'text', value: 'world' }, + ], + }; + + const proto = toolResultToProtocol(result, 'tool'); + assert.strictEqual(proto.content?.length, 3); + assert.deepStrictEqual(proto.content![0], { type: ToolResultContentType.Text, text: 'hello' }); + assert.strictEqual(proto.content![1].type, ToolResultContentType.EmbeddedResource); + assert.strictEqual((proto.content![1] as { contentType: string }).contentType, 'image/png'); + // Verify data is base64-encoded, not raw UTF-8 + const embeddedData = (proto.content![1] as { data: string }).data; + assert.ok(embeddedData.length > 0); + assert.notStrictEqual(embeddedData, 'hello binary'); // should be base64, not raw text + assert.deepStrictEqual(proto.content![2], { type: ToolResultContentType.Text, text: 'world' }); + }); + + test('converts data parts to EmbeddedResource with base64 encoding', () => { + const binaryData = VSBuffer.fromString('test data'); + const result: IToolResult = { + content: [ + { kind: 'data', value: { mimeType: 'image/png', data: binaryData } }, + ], + }; + + const proto = toolResultToProtocol(result, 'tool'); + assert.strictEqual(proto.content?.length, 1); + assert.strictEqual(proto.content![0].type, ToolResultContentType.EmbeddedResource); + const embedded = proto.content![0] as { data: string; contentType: string }; + assert.strictEqual(embedded.contentType, 'image/png'); + assert.ok(embedded.data.length > 0); + assert.notStrictEqual(embedded.data, 'test data'); // base64 encoded + }); + + test('uses boolean toolResultError as generic error message', () => { + const result: IToolResult = { + content: [], + toolResultError: true, + }; + + const proto = toolResultToProtocol(result, 'myTool'); + assert.strictEqual(proto.success, false); + assert.strictEqual(proto.error?.message, 'myTool encountered an error'); + }); + }); + + // ── AgentHostSessionHandler client tools integration ───────────────── + + suite('client tools registration', () => { + + function createMockToolsService(disposables: DisposableStore, tools: IToolData[]) { + const onDidChangeTools = disposables.add(new Emitter()); + return { + onDidChangeTools: onDidChangeTools.event, + getToolByName: (name: string) => tools.find(t => t.toolReferenceName === name), + observeTools: () => observableValue('tools', tools), + registerToolData: () => toDisposable(() => { }), + registerToolImplementation: () => toDisposable(() => { }), + registerTool: () => toDisposable(() => { }), + getTools: () => tools, + getAllToolsIncludingDisabled: () => tools, + getTool: () => undefined, + invokeTool: async () => ({ content: [] }), + beginToolCall: () => undefined, + updateToolStream: async () => { }, + cancelToolCallsForRequest: () => { }, + flushToolUpdates: () => { }, + toolSets: observableValue('sets', []), + getToolSetsForModel: () => [], + getToolSet: () => undefined, + getToolSetByName: () => undefined, + createToolSet: () => { throw new Error('not impl'); }, + getFullReferenceNames: () => [], + getFullReferenceName: () => '', + getToolByFullReferenceName: () => undefined, + getDeprecatedFullReferenceNames: () => new Map(), + toToolAndToolSetEnablementMap: () => new Map(), + toFullReferenceNames: () => [], + toToolReferences: () => [], + vscodeToolSet: undefined!, + executeToolSet: undefined!, + readToolSet: undefined!, + agentToolSet: undefined!, + onDidPrepareToolCallBecomeUnresponsive: Event.None, + onDidInvokeTool: Event.None, + _serviceBrand: undefined, + fireOnDidChangeTools: () => onDidChangeTools.fire(), + } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void }; + } + + class MockAgentHostConnection extends mock() { + declare readonly _serviceBrand: undefined; + override readonly clientId = 'test-client'; + private readonly _onDidAction = disposables.add(new Emitter()); + override readonly onDidAction = this._onDidAction.event; + private readonly _onDidNotification = disposables.add(new Emitter()); + override readonly onDidNotification = this._onDidNotification.event; + override readonly onAgentHostExit = Event.None; + override readonly onAgentHostStart = Event.None; + + private readonly _liveSubscriptions = new Map }>(); + public dispatchedActions: (ISessionAction | ITerminalAction)[] = []; + + override dispatch(action: ISessionAction | ITerminalAction): void { + this.dispatchedActions.push(action); + if (isSessionAction(action) && action.type === 'session/activeClientChanged') { + const entry = this._liveSubscriptions.get(action.session); + if (entry) { + entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); + entry.emitter.fire(entry.state); + } + } + if (isSessionAction(action) && action.type === 'session/activeClientToolsChanged') { + const entry = this._liveSubscriptions.get(action.session); + if (entry) { + entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); + entry.emitter.fire(entry.state); + } + } + } + + override readonly rootState: IAgentSubscription = { + value: undefined, + verifiedValue: undefined, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + + override getSubscription(_kind: StateComponents, resource: URI): IReference> { + const resourceStr = resource.toString(); + const emitter = disposables.add(new Emitter()); + const summary: ISessionSummary = { + resource: resourceStr, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + const initialState: ISessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + const entry = { state: initialState, emitter: emitter as unknown as Emitter }; + this._liveSubscriptions.set(resourceStr, entry); + + const self = this; + const sub: IAgentSubscription = { + get value() { return self._liveSubscriptions.get(resourceStr)?.state as unknown as T; }, + get verifiedValue() { return self._liveSubscriptions.get(resourceStr)?.state as unknown as T; }, + onDidChange: emitter.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + return { + object: sub, + dispose: () => { + this._liveSubscriptions.delete(resourceStr); + }, + }; + } + } + + function createHandlerWithMocks( + disposables: DisposableStore, + tools: IToolData[], + configOverrides?: { clientTools?: string[] }, + ) { + const instantiationService = disposables.add(new TestInstantiationService()); + const connection = new MockAgentHostConnection(); + + const toolsService = createMockToolsService(disposables, tools); + const configValues: Record = { + 'chat.agentHost.clientTools': configOverrides?.clientTools ?? ['runTask', 'runTests'], + }; + const onDidChangeConfig = disposables.add(new Emitter()); + const configService: Partial = { + getValue: (key: string) => configValues[key], + onDidChangeConfiguration: onDidChangeConfig.event, + } as Partial; + + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IProductService, { quality: 'insider' }); + instantiationService.stub(IChatAgentService, { + registerDynamicAgent: () => toDisposable(() => { }), + }); + instantiationService.stub(IFileService, TestFileService); + instantiationService.stub(ILabelService, MockLabelService); + instantiationService.stub(IChatSessionsService, { + registerChatSessionItemController: () => toDisposable(() => { }), + registerChatSessionContentProvider: () => toDisposable(() => { }), + registerChatSessionContribution: () => toDisposable(() => { }), + }); + instantiationService.stub(IDefaultAccountService, { onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null }); + instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None }); + instantiationService.stub(ILanguageModelsService, { + deltaLanguageModelChatProviderDescriptors: () => { }, + registerLanguageModelProvider: () => toDisposable(() => { }), + }); + instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IOutputService, { getChannel: () => undefined }); + instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null }); + instantiationService.stub(IChatEditingService, { + registerEditingSessionProvider: () => toDisposable(() => { }), + }); + instantiationService.stub(IChatService, { + getSession: () => undefined, + onDidCreateModel: Event.None, + removePendingRequest: () => { }, + }); + instantiationService.stub(IAgentHostFileSystemService, { + registerAuthority: () => toDisposable(() => { }), + ensureSyncedCustomizationProvider: () => { }, + }); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(ICustomizationHarnessService, { + registerExternalHarness: () => toDisposable(() => { }), + }); + instantiationService.stub(IAgentPluginService, { + plugins: observableValue('plugins', []), + }); + instantiationService.stub(ITerminalChatService, { + onDidContinueInBackground: Event.None, + registerTerminalInstanceWithToolSession: () => { }, + }); + instantiationService.stub(IAgentHostTerminalService, { + reviveTerminal: async () => undefined!, + }); + instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, { + registerResolver: () => toDisposable(() => { }), + resolve: () => undefined, + }); + instantiationService.stub(ILanguageModelToolsService, toolsService); + + const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { + provider: 'copilot' as const, + agentId: 'agent-host-copilot', + sessionType: 'agent-host-copilot', + fullName: 'Test', + description: 'Test', + connection, + connectionAuthority: 'local', + })); + + return { handler, connection, toolsService, configValues, onDidChangeConfig }; + } + + const testRunTestsTool: IToolData = { + id: 'vscode.runTests', + toolReferenceName: 'runTests', + displayName: 'Run Tests', + modelDescription: 'Runs unit tests', + source: ToolDataSource.Internal, + inputSchema: { type: 'object', properties: { files: { type: 'array' } } }, + }; + + const testRunTaskTool: IToolData = { + id: 'vscode.runTask', + toolReferenceName: 'runTask', + displayName: 'Run Task', + modelDescription: 'Runs a VS Code task', + source: ToolDataSource.Internal, + inputSchema: { type: 'object', properties: { task: { type: 'string' } } }, + }; + + const testUnlistedTool: IToolData = { + id: 'vscode.readFile', + toolReferenceName: 'readFile', + displayName: 'Read File', + modelDescription: 'Reads a file', + source: ToolDataSource.Internal, + }; + + test('maps allowlisted tool data to protocol definitions', async () => { + const { connection } = createHandlerWithMocks(disposables, [testRunTestsTool, testRunTaskTool, testUnlistedTool]); + + // The handler dispatches activeClientChanged in the constructor when + // customizations observable fires, but here it fires during provideChatSessionContent. + // Verify tools are built correctly by checking what would be dispatched. + assert.ok(connection); + + // Verify that the tool conversion works correctly for the allowlisted tools + const runTestsDef = toolDataToDefinition(testRunTestsTool); + assert.strictEqual(runTestsDef.name, 'runTests'); + assert.strictEqual(runTestsDef.title, 'Run Tests'); + assert.strictEqual(runTestsDef.description, 'Runs unit tests'); + }); + + test('filters tool data to entries in configured allowlist', () => { + createHandlerWithMocks(disposables, [testRunTestsTool, testRunTaskTool, testUnlistedTool], { + clientTools: ['runTests'], + }); + + // Validate the filtering logic: only 'runTests' should match the allowlist. + const filteredTools = [testRunTestsTool, testRunTaskTool, testUnlistedTool] + .filter(t => t.toolReferenceName !== undefined && ['runTests'].includes(t.toolReferenceName)); + assert.strictEqual(filteredTools.length, 1); + assert.strictEqual(filteredTools[0].toolReferenceName, 'runTests'); + }); + + test('dispatches activeClientToolsChanged when config changes', () => { + const { connection, configValues, onDidChangeConfig } = createHandlerWithMocks( + disposables, + [testRunTestsTool, testRunTaskTool], + ); + + // Simulate config change + configValues['chat.agentHost.clientTools'] = ['runTests']; + onDidChangeConfig.fire({ affectsConfiguration: (key: string) => key === 'chat.agentHost.clientTools' } as unknown as IConfigurationChangeEvent); + + // Since no session is active (no _sessionToBackend entries), + // no activeClientToolsChanged should be dispatched. + // But the observable should now reflect the new tools. + const toolsChangedActions = connection.dispatchedActions.filter( + a => isSessionAction(a) && a.type === 'session/activeClientToolsChanged' + ); + // No sessions active = no dispatches + assert.strictEqual(toolsChangedActions.length, 0); + }); + + test('handles tools with when clauses via observeTools filtering', () => { + // The observeTools method already filters by `when` clauses. + // When a tool has a `when` clause that doesn't match, it won't + // appear in the observable, and thus won't be included. + // Our mock observeTools returns all tools directly, but in + // production, tools with non-matching when clauses are excluded + // before reaching the allowlist filter. + const def = toolDataToDefinition(testRunTestsTool); + assert.strictEqual(def.name, 'runTests'); + }); + }); +});