Path: blob/main/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts
13399 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 { writeFileSync, unlinkSync } from 'fs';7import { fileURLToPath } from 'url';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';9import { DisposableStore } from '../../../../base/common/lifecycle.js';10import { Schemas } from '../../../../base/common/network.js';11import { URI } from '../../../../base/common/uri.js';12import { VSBuffer } from '../../../../base/common/buffer.js';13import { FileService } from '../../../files/common/fileService.js';14import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';15import { NullLogService } from '../../../log/common/log.js';16import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js';17import { toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js';18import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';1920suite('copilotPluginConverters', () => {2122const disposables = new DisposableStore();23let fileService: FileService;2425setup(() => {26fileService = disposables.add(new FileService(new NullLogService()));27disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));28});2930teardown(() => disposables.clear());31ensureNoDisposablesAreLeakedInTestSuite();3233// ---- toSdkMcpServers ------------------------------------------------3435suite('toSdkMcpServers', () => {3637test('converts local server definitions', () => {38const defs: IMcpServerDefinition[] = [{39name: 'test-server',40uri: URI.file('/plugin'),41configuration: {42type: McpServerType.LOCAL,43command: 'node',44args: ['server.js', '--port', '3000'],45env: { NODE_ENV: 'production', PORT: 3000 as unknown as string },46cwd: '/workspace',47},48}];4950const result = toSdkMcpServers(defs);51assert.deepStrictEqual(result, {52'test-server': {53type: 'local',54command: 'node',55args: ['server.js', '--port', '3000'],56tools: ['*'],57env: { NODE_ENV: 'production', PORT: '3000' },58cwd: '/workspace',59},60});61});6263test('converts remote/http server definitions', () => {64const defs: IMcpServerDefinition[] = [{65name: 'remote-server',66uri: URI.file('/plugin'),67configuration: {68type: McpServerType.REMOTE,69url: 'https://example.com/mcp',70headers: { 'Authorization': 'Bearer token' },71},72}];7374const result = toSdkMcpServers(defs);75assert.deepStrictEqual(result, {76'remote-server': {77type: 'http',78url: 'https://example.com/mcp',79tools: ['*'],80headers: { 'Authorization': 'Bearer token' },81},82});83});8485test('handles empty definitions', () => {86const result = toSdkMcpServers([]);87assert.deepStrictEqual(result, {});88});8990test('omits optional fields when undefined', () => {91const defs: IMcpServerDefinition[] = [{92name: 'minimal',93uri: URI.file('/plugin'),94configuration: {95type: McpServerType.LOCAL,96command: 'echo',97},98}];99100const result = toSdkMcpServers(defs);101assert.strictEqual(result['minimal'].type, 'local');102assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []);103assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false);104assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false);105});106107test('filters null values from env', () => {108const defs: IMcpServerDefinition[] = [{109name: 'with-null-env',110uri: URI.file('/plugin'),111configuration: {112type: McpServerType.LOCAL,113command: 'test',114env: { KEEP: 'value', DROP: null as unknown as string },115},116}];117118const result = toSdkMcpServers(defs);119const env = (result['with-null-env'] as { env?: Record<string, string> }).env;120assert.deepStrictEqual(env, { KEEP: 'value' });121});122});123124// ---- toSdkCustomAgents ----------------------------------------------125126suite('toSdkCustomAgents', () => {127128test('reads agent files and creates configs', async () => {129const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/helper.md' });130await fileService.writeFile(agentUri, VSBuffer.fromString('You are a helpful assistant'));131132const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }];133const result = await toSdkCustomAgents(agents, fileService);134135assert.deepStrictEqual(result, [{136name: 'helper',137prompt: 'You are a helpful assistant',138}]);139});140141test('skips agents whose files cannot be read', async () => {142const agents: INamedPluginResource[] = [143{ uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' },144];145const result = await toSdkCustomAgents(agents, fileService);146assert.deepStrictEqual(result, []);147});148149test('processes multiple agents, skipping failures', async () => {150const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' });151await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent'));152153const agents: INamedPluginResource[] = [154{ uri: goodUri, name: 'good' },155{ uri: URI.from({ scheme: Schemas.inMemory, path: '/agents/bad.md' }), name: 'bad' },156];157const result = await toSdkCustomAgents(agents, fileService);158assert.strictEqual(result.length, 1);159assert.strictEqual(result[0].name, 'good');160});161});162163// ---- toSdkSkillDirectories ------------------------------------------164165suite('toSdkSkillDirectories', () => {166167test('extracts parent directories of skill URIs', () => {168const skills: INamedPluginResource[] = [169{ uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' },170{ uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' },171];172const result = toSdkSkillDirectories(skills);173assert.strictEqual(result.length, 2);174});175176test('deduplicates directories', () => {177const skills: INamedPluginResource[] = [178{ uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' },179{ uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' },180];181const result = toSdkSkillDirectories(skills);182assert.strictEqual(result.length, 1);183});184185test('handles empty input', () => {186const result = toSdkSkillDirectories([]);187assert.deepStrictEqual(result, []);188});189});190191// ---- toSdkHooks -------------------------------------------------------192193suite('toSdkHooks', () => {194195function makeHookGroup(type: string, command: string): IParsedHookGroup {196return {197type,198commands: [{ command }],199uri: URI.file('/plugin/hooks.json'),200originalId: type,201};202}203204/**205* Writes a temp JS script that outputs JSON to stdout and returns206* a `node <path>` command. Works on both bash (/bin/sh -c) and207* cmd.exe without any shell-quoting issues.208* The script is written alongside the compiled test file which is209* guaranteed to exist, be writable, and have no spaces in CI.210*/211function echoJsonCmd(value: object): { command: string; cleanup: () => void } {212const json = JSON.stringify(value);213// fileURLToPath(new URL('.', import.meta.url)) is the Node ESM equivalent214// of __dirname and works on Node 12+, unlike import.meta.dirname (Node 21.2+).215const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '');216const filePath = `${dir}/vscode-test-hook-${Date.now()}.js`;217writeFileSync(filePath, `process.stdout.write(${JSON.stringify(json)});\n`);218// Do NOT quote the path: cmd.exe /c "node path" strips the outer quotes,219// leaving "node path" without inner quoting which cmd.exe handles cleanly.220const command = `node ${filePath}`;221return { command, cleanup: () => { try { unlinkSync(filePath); } catch { /* ignore */ } } };222}223224test('onPostToolUse returns parsed JSON output as hook result', async () => {225const expectedOutput = { additionalContext: 'Before presenting the plan, run review-plan skill' };226const { command, cleanup } = echoJsonCmd(expectedOutput);227try {228const hookGroup = makeHookGroup('PostToolUse', command);229const hooks = toSdkHooks([hookGroup]);230const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };231const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' });232assert.deepStrictEqual(result, expectedOutput);233} finally {234cleanup();235}236});237238test('onPostToolUse returns undefined when output is non-JSON', async () => {239// Use a script file so there are no cmd.exe quoting issues on Windows.240const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '');241const filePath = `${dir}/vscode-test-hook-nonjson-${Date.now()}.js`;242writeFileSync(filePath, `process.stdout.write('not-json');\n`);243try {244const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`);245const hooks = toSdkHooks([hookGroup]);246const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };247const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' });248assert.strictEqual(result, undefined);249} finally {250try { unlinkSync(filePath); } catch { /* ignore */ }251}252});253254test('onPostToolUse returns undefined when command fails', async () => {255const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '');256const filePath = `${dir}/vscode-test-hook-fail-${Date.now()}.js`;257writeFileSync(filePath, `process.exit(1);\n`);258try {259const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`);260const hooks = toSdkHooks([hookGroup]);261const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };262const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' });263assert.strictEqual(result, undefined);264} finally {265try { unlinkSync(filePath); } catch { /* ignore */ }266}267});268269test('onPostToolUse returns undefined when no commands', async () => {270const hooks = toSdkHooks([]);271assert.strictEqual(hooks.onPostToolUse, undefined);272});273274test('onPostToolUse calls editTrackingHooks and returns command output', async () => {275const expectedOutput = { additionalContext: 'context from hook' };276const { command, cleanup } = echoJsonCmd(expectedOutput);277try {278const hookGroup = makeHookGroup('PostToolUse', command);279let trackingInput: unknown;280const editTrackingHooks = {281onPreToolUse: async () => { },282onPostToolUse: async (input: unknown) => { trackingInput = input; },283};284const hooks = toSdkHooks([hookGroup], editTrackingHooks);285const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };286const callInput = { toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' };287const result = await hooks.onPostToolUse!(callInput, { sessionId: 'test' });288assert.deepStrictEqual(result, expectedOutput);289assert.deepStrictEqual(trackingInput, callInput);290} finally {291cleanup();292}293});294});295296// ---- parsedPluginsEqual ---------------------------------------------297298suite('parsedPluginsEqual', () => {299300function makePlugin(overrides?: Partial<IParsedPlugin>): IParsedPlugin {301return {302hooks: [],303mcpServers: [],304skills: [],305agents: [],306...overrides,307};308}309310test('returns true for identical empty plugins', () => {311assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true);312});313314test('returns true for same content', () => {315const a = makePlugin({316skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }],317mcpServers: [{318name: 'server',319uri: URI.file('/mcp'),320configuration: { type: McpServerType.LOCAL, command: 'node' },321}],322});323const b = makePlugin({324skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }],325mcpServers: [{326name: 'server',327uri: URI.file('/mcp'),328configuration: { type: McpServerType.LOCAL, command: 'node' },329}],330});331assert.strictEqual(parsedPluginsEqual([a], [b]), true);332});333334test('returns false for different content', () => {335const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] });336const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] });337assert.strictEqual(parsedPluginsEqual([a], [b]), false);338});339340test('returns false for different lengths', () => {341assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false);342});343344test('returns true for empty arrays', () => {345assert.strictEqual(parsedPluginsEqual([], []), true);346});347});348});349350351