Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { CancellationToken } from '../../../../../base/common/cancellation.js';7import { Emitter, Event } from '../../../../../base/common/event.js';8import { mock } from '../../../../../base/test/common/mock.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';10import { type IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js';11import { ActionType, type ActionEnvelope, type INotification, type StateAction } from '../../../../../platform/agentHost/common/state/sessionActions.js';12import { CustomizationStatus, type AgentInfo, type CustomizationRef, type RootState, type SessionCustomization } from '../../../../../platform/agentHost/common/state/protocol/state.js';13import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';14import { VSBuffer } from '../../../../../base/common/buffer.js';15import { IFileService, type IFileContent, type IFileStat, type IFileStatResult } from '../../../../../platform/files/common/files.js';16import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';17import { NullLogService } from '../../../../../platform/log/common/log.js';18import { INotificationService } from '../../../../../platform/notification/common/notification.js';19import { URI } from '../../../../../base/common/uri.js';20import { IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';21import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js';22import { RemoteAgentCustomizationItemProvider, RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js';2324class MockAgentConnection extends mock<IAgentConnection>() {25declare readonly _serviceBrand: undefined;2627private readonly _onDidAction = new Emitter<ActionEnvelope>();28override readonly onDidAction = this._onDidAction.event;29override readonly onDidNotification = Event.None as Event<INotification>;30override readonly clientId = 'test-client';3132private _rootStateValue: RootState = { agents: [] };33override readonly rootState;3435readonly dispatchedActions: StateAction[] = [];3637constructor() {38super();39const self = this;40this.rootState = {41get value(): RootState { return self._rootStateValue; },42get verifiedValue(): RootState { return self._rootStateValue; },43onDidChange: Event.None,44onWillApplyAction: Event.None,45onDidApplyAction: Event.None,46};47}4849setRootState(rootState: RootState): void {50this._rootStateValue = rootState;51}5253override dispatch(action: StateAction): void {54this.dispatchedActions.push(action);55}5657fireAction(envelope: ActionEnvelope): void {58this._onDidAction.fire(envelope);59}6061dispose(): void {62this._onDidAction.dispose();63}64}6566function createNotificationService(): INotificationService {67return new class extends mock<INotificationService>() {68override error(): never {69throw new Error('Unexpected notification error');70}71};72}7374function createAgentInfo(customizations: readonly CustomizationRef[]): AgentInfo {75return {76provider: 'copilotcli',77displayName: 'Copilot',78description: 'Test Agent',79models: [],80customizations: [...customizations],81};82}8384suite('RemoteAgentHostCustomizationHarness', () => {85const disposables = ensureNoDisposablesAreLeakedInTestSuite();8687test('removeConfiguredPlugin keeps sibling scopes for the same URI', async () => {88const connection = disposables.add(new MockAgentConnection());89const controller = disposables.add(new RemoteAgentPluginController(90'Test Host',91'test-authority',92connection,93{} as IFileDialogService,94createNotificationService(),95{} as IAICustomizationWorkspaceService,96));97const pluginA: CustomizationRef = { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' };98const pluginB: CustomizationRef = {99uri: 'file:///plugins/other',100displayName: 'Other Plugin',101};102connection.setRootState({103agents: [],104config: {105schema: { type: 'object', properties: {} },106values: { customizations: [pluginA, pluginB] },107},108});109110await controller.removeConfiguredPlugin(pluginA);111112assert.deepStrictEqual(connection.dispatchedActions, [{113type: ActionType.RootConfigChanged,114config: {115customizations: [pluginB],116},117}]);118});119120test('provider assigns distinct item keys to plugins with different URIs', async () => {121const connection = disposables.add(new MockAgentConnection());122const controller = disposables.add(new RemoteAgentPluginController(123'Test Host',124'test-authority',125connection,126{} as IFileDialogService,127createNotificationService(),128{} as IAICustomizationWorkspaceService,129));130const pluginA: CustomizationRef = { uri: 'file:///plugins/a', displayName: 'Plugin A' };131const pluginB: CustomizationRef = { uri: 'file:///plugins/b', displayName: 'Plugin B' };132133connection.setRootState({134agents: [createAgentInfo([pluginA, pluginB])],135});136137const fileService = new class extends mock<IFileService>() {138override async canHandleResource() { return false; }139override async resolveAll() { return []; }140};141142const provider = disposables.add(new RemoteAgentCustomizationItemProvider(143createAgentInfo([pluginA, pluginB]),144connection,145'test-authority',146controller,147fileService,148new NullLogService(),149));150151const items = await provider.provideChatSessionCustomizations(CancellationToken.None);152assert.strictEqual(items.length, 2);153assert.notStrictEqual(items[0].itemKey, items[1].itemKey);154});155156test('provider keeps client-synced entries distinct from host-owned entries', async () => {157const connection = disposables.add(new MockAgentConnection());158const controller = disposables.add(new RemoteAgentPluginController(159'Test Host',160'test-authority',161connection,162{} as IFileDialogService,163createNotificationService(),164{} as IAICustomizationWorkspaceService,165));166const hostScoped: CustomizationRef = { uri: 'file:///plugins/shared', displayName: 'Shared Plugin' };167const synced: SessionCustomization = {168customization: hostScoped,169clientId: 'test-client',170enabled: true,171};172173connection.setRootState({174agents: [createAgentInfo([hostScoped])],175});176177const fileService = new class extends mock<IFileService>() {178override async canHandleResource() { return false; }179override async resolveAll() { return []; }180};181182const provider = disposables.add(new RemoteAgentCustomizationItemProvider(183createAgentInfo([hostScoped]),184connection,185'test-authority',186controller,187fileService,188new NullLogService(),189));190191connection.fireAction({192serverSeq: 1,193origin: undefined,194action: {195type: ActionType.SessionCustomizationsChanged,196session: 'agent://copilotcli/session-1',197customizations: [synced],198},199});200201const items = await provider.provideChatSessionCustomizations(CancellationToken.None);202assert.strictEqual(items.length, 2);203assert.notStrictEqual(items[0].itemKey, items[1].itemKey);204});205206test('provider assigns client group to client-synced entries and host group to host entries', async () => {207const connection = disposables.add(new MockAgentConnection());208const controller = disposables.add(new RemoteAgentPluginController(209'Test Host',210'test-authority',211connection,212{} as IFileDialogService,213createNotificationService(),214{} as IAICustomizationWorkspaceService,215));216const hostPlugin: CustomizationRef = { uri: 'file:///plugins/host-plugin', displayName: 'Host Plugin' };217const clientPlugin: CustomizationRef = { uri: 'file:///plugins/client-plugin', displayName: 'Client Plugin' };218const synced: SessionCustomization = {219customization: clientPlugin,220clientId: 'test-client',221enabled: true,222};223224connection.setRootState({225agents: [createAgentInfo([hostPlugin])],226});227228const fileService = new class extends mock<IFileService>() {229override async canHandleResource() { return false; }230override async resolveAll() { return []; }231};232233const provider = disposables.add(new RemoteAgentCustomizationItemProvider(234createAgentInfo([hostPlugin]),235connection,236'test-authority',237controller,238fileService,239new NullLogService(),240));241242connection.fireAction({243serverSeq: 1,244origin: undefined,245action: {246type: ActionType.SessionCustomizationsChanged,247session: 'agent://copilotcli/session-1',248customizations: [synced],249},250});251252const items = await provider.provideChatSessionCustomizations(CancellationToken.None);253assert.strictEqual(items.length, 2);254255const hostItem = items.find(i => i.name === 'Host Plugin');256const clientItem = items.find(i => i.name === 'Client Plugin');257assert.ok(hostItem, 'should have a host item');258assert.ok(clientItem, 'should have a client item');259assert.strictEqual(hostItem.groupKey, 'remote-host');260assert.strictEqual(clientItem.groupKey, 'remote-client');261});262263test('provider hides synthetic bundle but still expands its contents', async () => {264const connection = disposables.add(new MockAgentConnection());265const controller = disposables.add(new RemoteAgentPluginController(266'Test Host',267'test-authority',268connection,269{} as IFileDialogService,270createNotificationService(),271{} as IAICustomizationWorkspaceService,272));273274const bundleUri = `${SYNCED_CUSTOMIZATION_SCHEME}:///test-authority`;275const bundleRef: CustomizationRef = { uri: bundleUri, displayName: 'VS Code Synced Data', nonce: 'abc' };276const synced: SessionCustomization = {277customization: bundleRef,278clientId: 'test-client',279enabled: true,280status: CustomizationStatus.Loaded,281};282283connection.setRootState({ agents: [createAgentInfo([])] });284285// Mock file service that returns a skills directory with one child286const skillFileUri = URI.parse(`${bundleUri}/skills/my-skill`);287const fileService = new class extends mock<IFileService>() {288override async canHandleResource() { return true; }289override async resolveAll(resources: { resource: URI }[]): Promise<IFileStatResult[]> {290return resources.map(r => {291if (r.resource.path.endsWith('/skills')) {292return {293success: true,294stat: {295resource: r.resource,296name: 'skills',297isFile: false,298isDirectory: true,299isSymbolicLink: false,300readonly: false,301mtime: 0,302ctime: 0,303size: 0,304children: [{305name: 'my-skill',306resource: skillFileUri,307isFile: false,308isDirectory: true,309isSymbolicLink: false,310readonly: false,311mtime: 0,312ctime: 0,313size: 0,314children: [],315}],316},317} satisfies IFileStatResult;318}319return { success: false, stat: undefined } as unknown as IFileStatResult;320});321}322override async readFile(resource: URI): Promise<IFileContent> {323if (resource.path.endsWith('/my-skill/SKILL.md')) {324const content = '---\n---\n';325return { resource, name: 'SKILL.md', value: VSBuffer.fromString(content), mtime: 0, ctime: 0, etag: '', size: content.length, readonly: false, locked: false, executable: false };326}327throw new Error('ENOENT');328}329};330331const provider = disposables.add(new RemoteAgentCustomizationItemProvider(332createAgentInfo([]),333connection,334'test-authority',335controller,336fileService,337new NullLogService(),338));339340connection.fireAction({341serverSeq: 1,342origin: undefined,343action: {344type: ActionType.SessionCustomizationsChanged,345session: 'agent://copilotcli/session-1',346customizations: [synced],347},348});349350const items = await provider.provideChatSessionCustomizations(CancellationToken.None);351// The synthetic bundle itself should NOT appear as a top-level item352assert.ok(!items.some(i => i.name === 'VS Code Synced Data'), 'synthetic bundle should be hidden');353// But its expanded child should appear354const skillItem = items.find(i => i.name === 'my-skill');355assert.ok(skillItem, 'expanded skill from bundle should be present');356assert.strictEqual(skillItem.groupKey, 'remote-client', 'expanded children from bundle should be in client group');357});358359test('toRemoteUri preserves synced-customization scheme URIs', async () => {360const connection = disposables.add(new MockAgentConnection());361const controller = disposables.add(new RemoteAgentPluginController(362'Test Host',363'test-authority',364connection,365{} as IFileDialogService,366createNotificationService(),367{} as IAICustomizationWorkspaceService,368));369370const bundleUri = `${SYNCED_CUSTOMIZATION_SCHEME}:///test-authority`;371const bundleRef: CustomizationRef = { uri: bundleUri, displayName: 'VS Code Synced Data', nonce: 'abc' };372const synced: SessionCustomization = {373customization: bundleRef,374clientId: 'test-client',375enabled: true,376};377378connection.setRootState({ agents: [createAgentInfo([])] });379380const fileService = new class extends mock<IFileService>() {381override async canHandleResource() { return false; }382override async resolveAll() { return []; }383};384385const provider = disposables.add(new RemoteAgentCustomizationItemProvider(386createAgentInfo([]),387connection,388'test-authority',389controller,390fileService,391new NullLogService(),392));393394connection.fireAction({395serverSeq: 1,396origin: undefined,397action: {398type: ActionType.SessionCustomizationsChanged,399session: 'agent://copilotcli/session-1',400customizations: [synced],401},402});403404const items = await provider.provideChatSessionCustomizations(CancellationToken.None);405// No top-level item (bundle is hidden), but check that plugin expansion406// attempted with the original scheme — not agent-host://407// This is verified indirectly: canHandleResource returns false so408// no children are produced, but importantly no crash occurred409// (toAgentHostUri would throw for this scheme).410assert.strictEqual(items.length, 0);411});412413test('provider propagates status and enabled from session customizations', async () => {414const connection = disposables.add(new MockAgentConnection());415const controller = disposables.add(new RemoteAgentPluginController(416'Test Host',417'test-authority',418connection,419{} as IFileDialogService,420createNotificationService(),421{} as IAICustomizationWorkspaceService,422));423424const pluginRef: CustomizationRef = { uri: 'file:///plugins/my-plugin', displayName: 'My Plugin' };425const sessionCustomization: SessionCustomization = {426customization: pluginRef,427enabled: false,428status: CustomizationStatus.Error,429statusMessage: 'something went wrong',430};431432connection.setRootState({ agents: [createAgentInfo([pluginRef])] });433434const fileService = new class extends mock<IFileService>() {435override async canHandleResource() { return false; }436override async resolveAll() { return []; }437};438439const provider = disposables.add(new RemoteAgentCustomizationItemProvider(440createAgentInfo([pluginRef]),441connection,442'test-authority',443controller,444fileService,445new NullLogService(),446));447448connection.fireAction({449serverSeq: 1,450origin: undefined,451action: {452type: ActionType.SessionCustomizationsChanged,453session: 'agent://copilotcli/session-1',454customizations: [sessionCustomization],455},456});457458const items = await provider.provideChatSessionCustomizations(CancellationToken.None);459// Host-scoped plugin from root + session customization → merged into one entry460// The session customization entry updates status/statusMessage461const sessionItem = items.find(i => i.status === 'error');462assert.ok(sessionItem, 'should have an item with error status');463assert.strictEqual(sessionItem.statusMessage, 'something went wrong');464});465466test('provider fires change event on SessionCustomizationsChanged action', async () => {467const connection = disposables.add(new MockAgentConnection());468const controller = disposables.add(new RemoteAgentPluginController(469'Test Host',470'test-authority',471connection,472{} as IFileDialogService,473createNotificationService(),474{} as IAICustomizationWorkspaceService,475));476477const pluginRef: CustomizationRef = { uri: 'file:///plugins/host', displayName: 'Host Plugin' };478connection.setRootState({ agents: [createAgentInfo([pluginRef])] });479480const fileService = new class extends mock<IFileService>() {481override async canHandleResource() { return false; }482override async resolveAll() { return []; }483};484485const provider = disposables.add(new RemoteAgentCustomizationItemProvider(486createAgentInfo([pluginRef]),487connection,488'test-authority',489controller,490fileService,491new NullLogService(),492));493494let changeCount = 0;495disposables.add(provider.onDidChange(() => changeCount++));496497connection.fireAction({498serverSeq: 1,499origin: undefined,500action: {501type: ActionType.SessionCustomizationsChanged,502session: 'agent://copilotcli/session-1',503customizations: [{504customization: pluginRef,505enabled: true,506}],507},508});509510assert.strictEqual(changeCount, 1, 'should fire change event on session customization action');511});512513test('provider does not show remove action for client-synced plugins', async () => {514const connection = disposables.add(new MockAgentConnection());515const controller = disposables.add(new RemoteAgentPluginController(516'Test Host',517'test-authority',518connection,519{} as IFileDialogService,520createNotificationService(),521{} as IAICustomizationWorkspaceService,522));523524const hostPlugin: CustomizationRef = { uri: 'file:///plugins/host', displayName: 'Host Plugin' };525const clientPlugin: CustomizationRef = { uri: 'file:///plugins/client', displayName: 'Client Plugin' };526527connection.setRootState({ agents: [createAgentInfo([hostPlugin])] });528529const fileService = new class extends mock<IFileService>() {530override async canHandleResource() { return false; }531override async resolveAll() { return []; }532};533534const provider = disposables.add(new RemoteAgentCustomizationItemProvider(535createAgentInfo([hostPlugin]),536connection,537'test-authority',538controller,539fileService,540new NullLogService(),541));542543connection.fireAction({544serverSeq: 1,545origin: undefined,546action: {547type: ActionType.SessionCustomizationsChanged,548session: 'agent://copilotcli/session-1',549customizations: [{550customization: clientPlugin,551clientId: 'test-client',552enabled: true,553}],554},555});556557const items = await provider.provideChatSessionCustomizations(CancellationToken.None);558const hostItem = items.find(i => i.name === 'Host Plugin');559const clientItem = items.find(i => i.name === 'Client Plugin');560561assert.ok(hostItem, 'should have host item');562assert.ok(clientItem, 'should have client item');563assert.ok(hostItem.actions && hostItem.actions.length > 0, 'host item should have remove action');564assert.strictEqual(clientItem.actions, undefined, 'client item should have no actions');565});566567test('removeConfiguredPlugin dispatches updated list without the removed plugin', async () => {568const connection = disposables.add(new MockAgentConnection());569const controller = disposables.add(new RemoteAgentPluginController(570'Test Host',571'test-authority',572connection,573{} as IFileDialogService,574createNotificationService(),575{} as IAICustomizationWorkspaceService,576));577578const pluginA: CustomizationRef = { uri: 'file:///plugins/a', displayName: 'Plugin A' };579const pluginB: CustomizationRef = { uri: 'file:///plugins/b', displayName: 'Plugin B' };580const pluginC: CustomizationRef = { uri: 'file:///plugins/c', displayName: 'Plugin C' };581582connection.setRootState({583agents: [],584config: {585schema: { type: 'object', properties: {} },586values: { customizations: [pluginA, pluginB, pluginC] },587},588});589590await controller.removeConfiguredPlugin(pluginB);591592assert.strictEqual(connection.dispatchedActions.length, 1);593assert.deepStrictEqual(connection.dispatchedActions[0], {594type: ActionType.RootConfigChanged,595config: {596customizations: [pluginA, pluginC],597},598});599});600601test('multiple client-synced entries all appear with distinct keys', async () => {602const connection = disposables.add(new MockAgentConnection());603const controller = disposables.add(new RemoteAgentPluginController(604'Test Host',605'test-authority',606connection,607{} as IFileDialogService,608createNotificationService(),609{} as IAICustomizationWorkspaceService,610));611612const clientA: CustomizationRef = { uri: 'file:///plugins/client-a', displayName: 'Client A' };613const clientB: CustomizationRef = { uri: 'file:///plugins/client-b', displayName: 'Client B' };614615connection.setRootState({ agents: [createAgentInfo([])] });616617const fileService = new class extends mock<IFileService>() {618override async canHandleResource() { return false; }619override async resolveAll() { return []; }620};621622const provider = disposables.add(new RemoteAgentCustomizationItemProvider(623createAgentInfo([]),624connection,625'test-authority',626controller,627fileService,628new NullLogService(),629));630631connection.fireAction({632serverSeq: 1,633origin: undefined,634action: {635type: ActionType.SessionCustomizationsChanged,636session: 'agent://copilotcli/session-1',637customizations: [638{ customization: clientA, clientId: 'test-client', enabled: true },639{ customization: clientB, clientId: 'test-client', enabled: true },640],641},642});643644const items = await provider.provideChatSessionCustomizations(CancellationToken.None);645assert.strictEqual(items.length, 2);646assert.ok(items.find(i => i.name === 'Client A'), 'should have Client A');647assert.ok(items.find(i => i.name === 'Client B'), 'should have Client B');648const keys = items.map(i => i.itemKey);649assert.strictEqual(new Set(keys).size, 2, 'all item keys should be unique');650});651652test('provider parses skill metadata, rewrites folder URIs to SKILL.md, and skips unreadable folder skills', async () => {653const connection = disposables.add(new MockAgentConnection());654const controller = disposables.add(new RemoteAgentPluginController(655'Test Host',656'test-authority',657connection,658{} as IFileDialogService,659createNotificationService(),660{} as IAICustomizationWorkspaceService,661));662const plugin: CustomizationRef = { uri: 'file:///plugins/skills-bundle', displayName: 'Skills Bundle' };663664connection.setRootState({ agents: [createAgentInfo([plugin])] });665666// Build a synthetic plugin that contains a `skills/` directory with:667// - `valid-skill/` folder (SKILL.md parses with name + description)668// - `broken-skill/` folder (SKILL.md read fails — entry should be skipped)669// - `legacy.skill.md` flat file (kept as-is, name from filename)670const skillsDirChildren: IFileStat[] = [671{ name: 'valid-skill', resource: URI.parse('vscode-agent-host://test/plugins/skills-bundle/skills/valid-skill'), isFile: false, isDirectory: true, isSymbolicLink: false, children: undefined },672{ name: 'broken-skill', resource: URI.parse('vscode-agent-host://test/plugins/skills-bundle/skills/broken-skill'), isFile: false, isDirectory: true, isSymbolicLink: false, children: undefined },673{ name: 'legacy.skill.md', resource: URI.parse('vscode-agent-host://test/plugins/skills-bundle/skills/legacy.skill.md'), isFile: true, isDirectory: false, isSymbolicLink: false, children: undefined },674];675676const fileService = new class extends mock<IFileService>() {677override async canHandleResource() { return true; }678override async resolveAll(toResolve: { resource: URI }[]): Promise<IFileStatResult[]> {679return toResolve.map(({ resource }) => {680if (resource.path.endsWith('/skills')) {681return {682success: true,683stat: { name: 'skills', resource, isFile: false, isDirectory: true, isSymbolicLink: false, children: skillsDirChildren },684};685}686return { success: false };687});688}689override async readFile(resource: URI): Promise<IFileContent> {690if (resource.path.endsWith('/valid-skill/SKILL.md')) {691const content = '---\nname: Pretty Name\ndescription: A friendly skill description\n---\n\n# Body\n';692return { resource, name: 'SKILL.md', value: VSBuffer.fromString(content), mtime: 0, ctime: 0, etag: '', size: content.length, readonly: false, locked: false, executable: false };693}694throw new Error('ENOENT');695}696};697698const provider = disposables.add(new RemoteAgentCustomizationItemProvider(699createAgentInfo([plugin]),700connection,701'test-authority',702controller,703fileService,704new NullLogService(),705));706707const items = await provider.provideChatSessionCustomizations(CancellationToken.None);708709const skillItems = items.filter(i => i.type === PromptsType.skill);710assert.deepStrictEqual(711skillItems.map(i => ({ name: i.name, description: i.description, uri: i.uri.toString() })).sort((a, b) => a.name.localeCompare(b.name)),712[713{ name: 'Pretty Name', description: 'A friendly skill description', uri: 'vscode-agent-host://test/plugins/skills-bundle/skills/valid-skill/SKILL.md' },714{ name: 'legacy', description: undefined, uri: 'vscode-agent-host://test/plugins/skills-bundle/skills/legacy.skill.md' },715].sort((a, b) => a.name.localeCompare(b.name)),716);717});718});719720721