Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/copilotPluginConverters.test.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { writeFileSync, unlinkSync } from 'fs';
8
import { fileURLToPath } from 'url';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
10
import { DisposableStore } from '../../../../base/common/lifecycle.js';
11
import { Schemas } from '../../../../base/common/network.js';
12
import { URI } from '../../../../base/common/uri.js';
13
import { VSBuffer } from '../../../../base/common/buffer.js';
14
import { FileService } from '../../../files/common/fileService.js';
15
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
16
import { NullLogService } from '../../../log/common/log.js';
17
import { McpServerType } from '../../../mcp/common/mcpPlatformTypes.js';
18
import { toSdkMcpServers, toSdkCustomAgents, toSdkSkillDirectories, parsedPluginsEqual, toSdkHooks } from '../../node/copilot/copilotPluginConverters.js';
19
import type { IMcpServerDefinition, INamedPluginResource, IParsedHookGroup, IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js';
20
21
suite('copilotPluginConverters', () => {
22
23
const disposables = new DisposableStore();
24
let fileService: FileService;
25
26
setup(() => {
27
fileService = disposables.add(new FileService(new NullLogService()));
28
disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider())));
29
});
30
31
teardown(() => disposables.clear());
32
ensureNoDisposablesAreLeakedInTestSuite();
33
34
// ---- toSdkMcpServers ------------------------------------------------
35
36
suite('toSdkMcpServers', () => {
37
38
test('converts local server definitions', () => {
39
const defs: IMcpServerDefinition[] = [{
40
name: 'test-server',
41
uri: URI.file('/plugin'),
42
configuration: {
43
type: McpServerType.LOCAL,
44
command: 'node',
45
args: ['server.js', '--port', '3000'],
46
env: { NODE_ENV: 'production', PORT: 3000 as unknown as string },
47
cwd: '/workspace',
48
},
49
}];
50
51
const result = toSdkMcpServers(defs);
52
assert.deepStrictEqual(result, {
53
'test-server': {
54
type: 'local',
55
command: 'node',
56
args: ['server.js', '--port', '3000'],
57
tools: ['*'],
58
env: { NODE_ENV: 'production', PORT: '3000' },
59
cwd: '/workspace',
60
},
61
});
62
});
63
64
test('converts remote/http server definitions', () => {
65
const defs: IMcpServerDefinition[] = [{
66
name: 'remote-server',
67
uri: URI.file('/plugin'),
68
configuration: {
69
type: McpServerType.REMOTE,
70
url: 'https://example.com/mcp',
71
headers: { 'Authorization': 'Bearer token' },
72
},
73
}];
74
75
const result = toSdkMcpServers(defs);
76
assert.deepStrictEqual(result, {
77
'remote-server': {
78
type: 'http',
79
url: 'https://example.com/mcp',
80
tools: ['*'],
81
headers: { 'Authorization': 'Bearer token' },
82
},
83
});
84
});
85
86
test('handles empty definitions', () => {
87
const result = toSdkMcpServers([]);
88
assert.deepStrictEqual(result, {});
89
});
90
91
test('omits optional fields when undefined', () => {
92
const defs: IMcpServerDefinition[] = [{
93
name: 'minimal',
94
uri: URI.file('/plugin'),
95
configuration: {
96
type: McpServerType.LOCAL,
97
command: 'echo',
98
},
99
}];
100
101
const result = toSdkMcpServers(defs);
102
assert.strictEqual(result['minimal'].type, 'local');
103
assert.deepStrictEqual((result['minimal'] as { args?: string[] }).args, []);
104
assert.strictEqual(Object.hasOwn(result['minimal'], 'env'), false);
105
assert.strictEqual(Object.hasOwn(result['minimal'], 'cwd'), false);
106
});
107
108
test('filters null values from env', () => {
109
const defs: IMcpServerDefinition[] = [{
110
name: 'with-null-env',
111
uri: URI.file('/plugin'),
112
configuration: {
113
type: McpServerType.LOCAL,
114
command: 'test',
115
env: { KEEP: 'value', DROP: null as unknown as string },
116
},
117
}];
118
119
const result = toSdkMcpServers(defs);
120
const env = (result['with-null-env'] as { env?: Record<string, string> }).env;
121
assert.deepStrictEqual(env, { KEEP: 'value' });
122
});
123
});
124
125
// ---- toSdkCustomAgents ----------------------------------------------
126
127
suite('toSdkCustomAgents', () => {
128
129
test('reads agent files and creates configs', async () => {
130
const agentUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/helper.md' });
131
await fileService.writeFile(agentUri, VSBuffer.fromString('You are a helpful assistant'));
132
133
const agents: INamedPluginResource[] = [{ uri: agentUri, name: 'helper' }];
134
const result = await toSdkCustomAgents(agents, fileService);
135
136
assert.deepStrictEqual(result, [{
137
name: 'helper',
138
prompt: 'You are a helpful assistant',
139
}]);
140
});
141
142
test('skips agents whose files cannot be read', async () => {
143
const agents: INamedPluginResource[] = [
144
{ uri: URI.from({ scheme: Schemas.inMemory, path: '/nonexistent/agent.md' }), name: 'missing' },
145
];
146
const result = await toSdkCustomAgents(agents, fileService);
147
assert.deepStrictEqual(result, []);
148
});
149
150
test('processes multiple agents, skipping failures', async () => {
151
const goodUri = URI.from({ scheme: Schemas.inMemory, path: '/agents/good.md' });
152
await fileService.writeFile(goodUri, VSBuffer.fromString('Good agent'));
153
154
const agents: INamedPluginResource[] = [
155
{ uri: goodUri, name: 'good' },
156
{ uri: URI.from({ scheme: Schemas.inMemory, path: '/agents/bad.md' }), name: 'bad' },
157
];
158
const result = await toSdkCustomAgents(agents, fileService);
159
assert.strictEqual(result.length, 1);
160
assert.strictEqual(result[0].name, 'good');
161
});
162
});
163
164
// ---- toSdkSkillDirectories ------------------------------------------
165
166
suite('toSdkSkillDirectories', () => {
167
168
test('extracts parent directories of skill URIs', () => {
169
const skills: INamedPluginResource[] = [
170
{ uri: URI.file('/plugins/skill-a/SKILL.md'), name: 'skill-a' },
171
{ uri: URI.file('/plugins/skill-b/SKILL.md'), name: 'skill-b' },
172
];
173
const result = toSdkSkillDirectories(skills);
174
assert.strictEqual(result.length, 2);
175
});
176
177
test('deduplicates directories', () => {
178
const skills: INamedPluginResource[] = [
179
{ uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-a' },
180
{ uri: URI.file('/plugins/shared/SKILL.md'), name: 'skill-b' },
181
];
182
const result = toSdkSkillDirectories(skills);
183
assert.strictEqual(result.length, 1);
184
});
185
186
test('handles empty input', () => {
187
const result = toSdkSkillDirectories([]);
188
assert.deepStrictEqual(result, []);
189
});
190
});
191
192
// ---- toSdkHooks -------------------------------------------------------
193
194
suite('toSdkHooks', () => {
195
196
function makeHookGroup(type: string, command: string): IParsedHookGroup {
197
return {
198
type,
199
commands: [{ command }],
200
uri: URI.file('/plugin/hooks.json'),
201
originalId: type,
202
};
203
}
204
205
/**
206
* Writes a temp JS script that outputs JSON to stdout and returns
207
* a `node <path>` command. Works on both bash (/bin/sh -c) and
208
* cmd.exe without any shell-quoting issues.
209
* The script is written alongside the compiled test file which is
210
* guaranteed to exist, be writable, and have no spaces in CI.
211
*/
212
function echoJsonCmd(value: object): { command: string; cleanup: () => void } {
213
const json = JSON.stringify(value);
214
// fileURLToPath(new URL('.', import.meta.url)) is the Node ESM equivalent
215
// of __dirname and works on Node 12+, unlike import.meta.dirname (Node 21.2+).
216
const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '');
217
const filePath = `${dir}/vscode-test-hook-${Date.now()}.js`;
218
writeFileSync(filePath, `process.stdout.write(${JSON.stringify(json)});\n`);
219
// Do NOT quote the path: cmd.exe /c "node path" strips the outer quotes,
220
// leaving "node path" without inner quoting which cmd.exe handles cleanly.
221
const command = `node ${filePath}`;
222
return { command, cleanup: () => { try { unlinkSync(filePath); } catch { /* ignore */ } } };
223
}
224
225
test('onPostToolUse returns parsed JSON output as hook result', async () => {
226
const expectedOutput = { additionalContext: 'Before presenting the plan, run review-plan skill' };
227
const { command, cleanup } = echoJsonCmd(expectedOutput);
228
try {
229
const hookGroup = makeHookGroup('PostToolUse', command);
230
const hooks = toSdkHooks([hookGroup]);
231
const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };
232
const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' });
233
assert.deepStrictEqual(result, expectedOutput);
234
} finally {
235
cleanup();
236
}
237
});
238
239
test('onPostToolUse returns undefined when output is non-JSON', async () => {
240
// Use a script file so there are no cmd.exe quoting issues on Windows.
241
const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '');
242
const filePath = `${dir}/vscode-test-hook-nonjson-${Date.now()}.js`;
243
writeFileSync(filePath, `process.stdout.write('not-json');\n`);
244
try {
245
const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`);
246
const hooks = toSdkHooks([hookGroup]);
247
const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };
248
const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' });
249
assert.strictEqual(result, undefined);
250
} finally {
251
try { unlinkSync(filePath); } catch { /* ignore */ }
252
}
253
});
254
255
test('onPostToolUse returns undefined when command fails', async () => {
256
const dir = fileURLToPath(new URL('.', import.meta.url)).replace(/[\\/]$/, '');
257
const filePath = `${dir}/vscode-test-hook-fail-${Date.now()}.js`;
258
writeFileSync(filePath, `process.exit(1);\n`);
259
try {
260
const hookGroup = makeHookGroup('PostToolUse', `node ${filePath}`);
261
const hooks = toSdkHooks([hookGroup]);
262
const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };
263
const result = await hooks.onPostToolUse!({ toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' }, { sessionId: 'test' });
264
assert.strictEqual(result, undefined);
265
} finally {
266
try { unlinkSync(filePath); } catch { /* ignore */ }
267
}
268
});
269
270
test('onPostToolUse returns undefined when no commands', async () => {
271
const hooks = toSdkHooks([]);
272
assert.strictEqual(hooks.onPostToolUse, undefined);
273
});
274
275
test('onPostToolUse calls editTrackingHooks and returns command output', async () => {
276
const expectedOutput = { additionalContext: 'context from hook' };
277
const { command, cleanup } = echoJsonCmd(expectedOutput);
278
try {
279
const hookGroup = makeHookGroup('PostToolUse', command);
280
let trackingInput: unknown;
281
const editTrackingHooks = {
282
onPreToolUse: async () => { },
283
onPostToolUse: async (input: unknown) => { trackingInput = input; },
284
};
285
const hooks = toSdkHooks([hookGroup], editTrackingHooks);
286
const toolResult = { textResultForLlm: 'ok', resultType: 'success' as const };
287
const callInput = { toolName: 'memory', toolArgs: {}, toolResult, timestamp: 0, cwd: '/' };
288
const result = await hooks.onPostToolUse!(callInput, { sessionId: 'test' });
289
assert.deepStrictEqual(result, expectedOutput);
290
assert.deepStrictEqual(trackingInput, callInput);
291
} finally {
292
cleanup();
293
}
294
});
295
});
296
297
// ---- parsedPluginsEqual ---------------------------------------------
298
299
suite('parsedPluginsEqual', () => {
300
301
function makePlugin(overrides?: Partial<IParsedPlugin>): IParsedPlugin {
302
return {
303
hooks: [],
304
mcpServers: [],
305
skills: [],
306
agents: [],
307
...overrides,
308
};
309
}
310
311
test('returns true for identical empty plugins', () => {
312
assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin()]), true);
313
});
314
315
test('returns true for same content', () => {
316
const a = makePlugin({
317
skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }],
318
mcpServers: [{
319
name: 'server',
320
uri: URI.file('/mcp'),
321
configuration: { type: McpServerType.LOCAL, command: 'node' },
322
}],
323
});
324
const b = makePlugin({
325
skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }],
326
mcpServers: [{
327
name: 'server',
328
uri: URI.file('/mcp'),
329
configuration: { type: McpServerType.LOCAL, command: 'node' },
330
}],
331
});
332
assert.strictEqual(parsedPluginsEqual([a], [b]), true);
333
});
334
335
test('returns false for different content', () => {
336
const a = makePlugin({ skills: [{ uri: URI.file('/a/SKILL.md'), name: 'a' }] });
337
const b = makePlugin({ skills: [{ uri: URI.file('/b/SKILL.md'), name: 'b' }] });
338
assert.strictEqual(parsedPluginsEqual([a], [b]), false);
339
});
340
341
test('returns false for different lengths', () => {
342
assert.strictEqual(parsedPluginsEqual([makePlugin()], [makePlugin(), makePlugin()]), false);
343
});
344
345
test('returns true for empty arrays', () => {
346
assert.strictEqual(parsedPluginsEqual([], []), true);
347
});
348
});
349
});
350
351