Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/common/test/copilotCLITools.spec.ts
13406 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 { describe, expect, it } from 'vitest';
7
import type { ChatPromptReference } from 'vscode';
8
import { TestLogService } from '../../../../../platform/testing/common/testLogService';
9
import { mock } from '../../../../../util/common/test/simpleMock';
10
import { URI } from '../../../../../util/vs/base/common/uri';
11
import {
12
ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponsePullRequestPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart, MarkdownString
13
} from '../../../../../vscodeTypes';
14
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
15
import {
16
buildChatHistoryFromEvents, createCopilotCLIToolInvocation, enrichToolInvocationWithSubagentMetadata, extractCdPrefix, FakeToolsService, getAffectedUrisForEditTool, isCopilotCliEditToolCall, isCopilotCLIToolThatCouldRequirePermissions, isTodoRelatedSqlQuery, processToolExecutionComplete, processToolExecutionStart, RequestIdDetails, stripReminders, ToolCall, updateTodoListFromSqlItems
17
} from '../copilotCLITools';
18
import { IChatDelegationSummaryService } from '../delegationSummaryService';
19
20
// Helper to extract invocation message text independent of MarkdownString vs string
21
function getInvocationMessageText(part: ChatToolInvocationPart | undefined): string {
22
if (!part) { return ''; }
23
const msg: any = part.invocationMessage;
24
if (!msg) { return ''; }
25
if (typeof msg === 'string') { return msg; }
26
if (msg instanceof MarkdownString) { return (msg as any).value ?? ''; }
27
return msg.value ?? '';
28
}
29
30
const getVSCodeRequestId = () => undefined;
31
const delegationSummary = new class extends mock<IChatDelegationSummaryService>() {
32
override extractPrompt(sessionId: string, message: string): { prompt: string; reference: ChatPromptReference } | undefined {
33
return undefined;
34
}
35
};
36
37
describe('CopilotCLITools', () => {
38
const logger = new TestLogService();
39
describe('isCopilotCliEditToolCall', () => {
40
it('detects StrReplaceEditor edit commands (non-view)', () => {
41
expect(isCopilotCliEditToolCall({ toolName: 'str_replace_editor', arguments: { command: 'str_replace', path: '/tmp/a' } })).toBe(true);
42
expect(isCopilotCliEditToolCall({ toolName: 'str_replace_editor', arguments: { command: 'insert', path: '/tmp/a', new_str: '' } })).toBe(true);
43
expect(isCopilotCliEditToolCall({ toolName: 'str_replace_editor', arguments: { command: 'create', path: '/tmp/a' } })).toBe(true);
44
});
45
it('excludes StrReplaceEditor view command', () => {
46
expect(isCopilotCliEditToolCall({ toolName: 'str_replace_editor', arguments: { command: 'view', path: '/tmp/a' } })).toBe(false);
47
});
48
it('always true for Edit & Create tools', () => {
49
expect(isCopilotCliEditToolCall({ toolName: 'edit', arguments: { path: '' } })).toBe(true);
50
expect(isCopilotCliEditToolCall({ toolName: 'create', arguments: { path: '' } })).toBe(true);
51
});
52
});
53
54
describe('getAffectedUrisForEditTool', () => {
55
it('returns URI for edit tool with path', () => {
56
const [uri] = getAffectedUrisForEditTool({ toolName: 'str_replace_editor', arguments: { command: 'str_replace', path: '/tmp/file.txt' } });
57
expect(uri.toString()).toContain('/tmp/file.txt');
58
});
59
it('returns empty for non-edit view command', () => {
60
expect(getAffectedUrisForEditTool({ toolName: 'str_replace_editor', arguments: { command: 'view', path: '/tmp/file.txt' } })).toHaveLength(0);
61
});
62
});
63
64
describe('stripReminders', () => {
65
it('removes reminder blocks and trims', () => {
66
const input = ' <reminder>Keep this private</reminder>\nContent';
67
expect(stripReminders(input)).toBe('Content');
68
});
69
it('removes current datetime blocks', () => {
70
const input = '<current_datetime>2025-10-10</current_datetime> Now';
71
expect(stripReminders(input)).toBe('Now');
72
});
73
it('removes pr_metadata tags', () => {
74
const input = '<pr_metadata uri="u" title="t" description="d" author="a" linkTag="l"/> Body';
75
expect(stripReminders(input)).toBe('Body');
76
});
77
it('removes user_query blocks', () => {
78
const input = '<user_query>Hidden prompt</user_query> Visible';
79
expect(stripReminders(input)).toBe('Visible');
80
});
81
it('removes multiple constructs mixed', () => {
82
const input = '<reminder>x</reminder>One<current_datetime>y</current_datetime> <pr_metadata uri="u" title="t" description="d" author="a" linkTag="l"/>Two';
83
// Current behavior compacts content without guaranteeing spacing
84
expect(stripReminders(input)).toBe('OneTwo');
85
});
86
});
87
88
describe('buildChatHistoryFromEvents', () => {
89
it('builds turns with user and assistant messages including PR metadata', () => {
90
const events: any[] = [
91
{ type: 'user.message', data: { content: 'Hello', attachments: [] } },
92
{ type: 'assistant.message', data: { content: '<pr_metadata uri="https://example.com/pr/1" title="Fix&amp;Improve" description="Desc" author="Alice" linkTag="PR#1"/>This is the PR body.' } }
93
];
94
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
95
expect(turns).toHaveLength(2); // request + response
96
expect(turns[0]).toBeInstanceOf(ChatRequestTurn2);
97
expect(turns[1]).toBeInstanceOf(ChatResponseTurn2);
98
const responseParts: any = (turns[1] as any).response;
99
// ResponseParts is private-ish; fallback to accessing parts array property variations
100
const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts);
101
// First part should be PR metadata
102
const prPart = parts.find(p => p instanceof ChatResponsePullRequestPart);
103
expect(prPart).toBeTruthy();
104
const markdownPart = parts.find(p => p instanceof ChatResponseMarkdownPart);
105
expect(markdownPart).toBeTruthy();
106
if (prPart) {
107
expect((prPart as any).title).toBe('Fix&Improve'); // &amp; unescaped
108
// command is set with openPullRequestReroute
109
expect((prPart as any).command.command).toBe('github.copilot.chat.openPullRequestReroute');
110
}
111
if (markdownPart) {
112
expect((markdownPart as any).value?.value || (markdownPart as any).value).toContain('This is the PR body.');
113
}
114
});
115
116
it('createCopilotCLIToolInvocation formats str_replace_editor view with range', () => {
117
const invocation = createCopilotCLIToolInvocation({ toolName: 'str_replace_editor', toolCallId: 'id3', arguments: { command: 'view', path: '/tmp/file.ts', view_range: [1, 5] } }) as ChatToolInvocationPart;
118
expect(invocation).toBeInstanceOf(ChatToolInvocationPart);
119
const msg = typeof invocation.invocationMessage === 'string' ? invocation.invocationMessage : invocation.invocationMessage?.value;
120
expect(msg).toMatch(/Read/);
121
expect(msg).toMatch(/file.ts/);
122
});
123
124
it('includes tool invocation parts and thinking progress without duplication', () => {
125
const events: any[] = [
126
{ type: 'user.message', data: { content: 'Run a command', attachments: [] } },
127
{ type: 'tool.execution_start', data: { toolName: 'think', toolCallId: 'think-1', arguments: { thought: 'Considering options' } } },
128
{ type: 'tool.execution_complete', data: { toolName: 'think', toolCallId: 'think-1', success: true } },
129
{ type: 'tool.execution_start', data: { toolName: 'bash', toolCallId: 'bash-1', arguments: { command: 'echo hi', description: 'Echo' } } },
130
{ type: 'tool.execution_complete', data: { toolName: 'bash', toolCallId: 'bash-1', success: true } }
131
];
132
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
133
expect(turns).toHaveLength(2); // request + response
134
const responseTurn = turns[1] as ChatResponseTurn2;
135
const responseParts: any = (responseTurn as any).response;
136
const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts);
137
const thinkingParts = parts.filter(p => p instanceof ChatResponseThinkingProgressPart);
138
expect(thinkingParts).toHaveLength(1); // not duplicated on completion
139
const toolInvocations = parts.filter(p => p instanceof ChatToolInvocationPart);
140
expect(toolInvocations).toHaveLength(1); // bash only
141
const bashInvocation = toolInvocations[0] as ChatToolInvocationPart;
142
expect(getInvocationMessageText(bashInvocation)).toContain('Echo');
143
});
144
145
it('renders task_complete summary as markdown in chat history', () => {
146
const events: any[] = [
147
{ type: 'user.message', data: { content: 'Finish task', attachments: [] } },
148
{ type: 'tool.execution_start', data: { toolName: 'task_complete', toolCallId: 'tc-1', arguments: { summary: 'All tests are passing.' } } },
149
{ type: 'tool.execution_complete', data: { toolName: 'task_complete', toolCallId: 'tc-1', success: true } }
150
];
151
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
152
expect(turns).toHaveLength(2);
153
const responseTurn = turns[1] as ChatResponseTurn2;
154
const responseParts: any = (responseTurn as any).response;
155
const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts);
156
const markdownParts = parts.filter(p => p instanceof ChatResponseMarkdownPart);
157
expect(markdownParts).toHaveLength(1);
158
expect((markdownParts[0] as any).value?.value || (markdownParts[0] as any).value).toContain('All tests are passing.');
159
});
160
161
it('preserves response details on the final rebuilt response turn', () => {
162
const events: any[] = [
163
{ type: 'user.message', data: { content: 'Hello', attachments: [] } },
164
{ type: 'assistant.message', data: { content: 'Hi there' } }
165
];
166
const turns = buildChatHistoryFromEvents('', 'base', events, getVSCodeRequestId, delegationSummary, logger, undefined, undefined, 'Base • 2x');
167
expect(turns).toHaveLength(2);
168
const responseTurn = turns[1] as ChatResponseTurn2;
169
expect(responseTurn.result).toEqual({ details: 'Base • 2x' });
170
});
171
172
it('converts file attachments to references on user messages', () => {
173
const events: any[] = [
174
{
175
type: 'user.message', data: {
176
content: 'Check #myFile.ts',
177
attachments: [
178
{ type: 'file', path: '/workspace/myFile.ts', displayName: 'myFile.ts' }
179
]
180
}
181
},
182
];
183
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
184
expect(turns).toHaveLength(1);
185
const requestTurn = turns[0] as ChatRequestTurn2;
186
const refs = requestTurn.references;
187
const fileRef = refs.find(r => r.id === '/workspace/myFile.ts');
188
expect(fileRef).toBeTruthy();
189
expect(fileRef!.name).toBe('myFile.ts');
190
});
191
192
it('converts directory attachments using getFolderAttachmentPath', () => {
193
const events: any[] = [
194
{
195
type: 'user.message', data: {
196
content: 'Check #src',
197
attachments: [
198
{ type: 'directory', path: '/workspace/src', displayName: 'src' }
199
]
200
}
201
},
202
];
203
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
204
expect(turns).toHaveLength(1);
205
const requestTurn = turns[0] as ChatRequestTurn2;
206
const refs = requestTurn.references;
207
// Directory attachment should produce a reference
208
expect(refs.length).toBeGreaterThanOrEqual(1);
209
const dirRef = refs.find(r => r.id === '/workspace/src');
210
expect(dirRef).toBeTruthy();
211
});
212
213
it('filters out instruction file attachments', () => {
214
const events: any[] = [
215
{
216
type: 'user.message', data: {
217
content: 'Hello',
218
attachments: [
219
{ type: 'file', path: '/workspace/.github/copilot-instructions.md', displayName: 'copilot-instructions.md' },
220
{ type: 'file', path: '/workspace/.github/instructions/custom.md', displayName: 'custom.md' },
221
{ type: 'file', path: '/workspace/src/app.ts', displayName: 'app.ts' }
222
]
223
}
224
},
225
];
226
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
227
const requestTurn = turns[0] as ChatRequestTurn2;
228
const refs = requestTurn.references;
229
// Only app.ts should remain (instruction files are filtered out)
230
const paths = refs.map(r => r.id);
231
expect(paths).not.toContain('/workspace/.github/copilot-instructions.md');
232
expect(paths).not.toContain('/workspace/.github/instructions/custom.md');
233
expect(paths).toContain('/workspace/src/app.ts');
234
});
235
236
it('does not duplicate file attachments when URI already exists in extracted references', () => {
237
// Dedup is between prompt-extracted references and attachments
238
// (not between duplicate attachments themselves). Without prompt references,
239
// duplicate attachments both get added.
240
const events: any[] = [
241
{
242
type: 'user.message', data: {
243
content: 'Check this',
244
attachments: [
245
{ type: 'file', path: '/workspace/src/app.ts', displayName: 'app.ts' },
246
{ type: 'file', path: '/workspace/src/app.ts', displayName: 'app.ts' }
247
]
248
}
249
},
250
];
251
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
252
const requestTurn = turns[0] as ChatRequestTurn2;
253
// Both attachments are added because deduplications checks against
254
// prompt-extracted references (existingReferences), not against other attachments
255
const appRefs = requestTurn.references.filter(r => r.id === '/workspace/src/app.ts');
256
expect(appRefs).toHaveLength(2);
257
});
258
259
it('excludes subagent markdown from top-level history', () => {
260
const events: any[] = [
261
{ type: 'user.message', id: 'u1', data: { content: 'Do something', attachments: [] } },
262
// Top-level assistant message (no parentToolCallId)
263
{ type: 'assistant.message', id: 'a1', data: { messageId: 'msg-1', content: 'Top-level reply' } },
264
// Sub-agent delta (has parentToolCallId) — should be excluded
265
{ type: 'assistant.message_delta', id: 'a2', data: { messageId: 'msg-2', deltaContent: 'sub-agent thinking...', parentToolCallId: 'task-1' } },
266
// Sub-agent full message (has parentToolCallId) — should be excluded
267
{ type: 'assistant.message', id: 'a3', data: { messageId: 'msg-3', content: 'sub-agent result text', parentToolCallId: 'task-1' } },
268
// Top-level assistant message after subagent
269
{ type: 'assistant.message', id: 'a4', data: { messageId: 'msg-4', content: 'Final answer' } },
270
];
271
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
272
expect(turns).toHaveLength(2); // 1 request + 1 response
273
const responseTurn = turns[1] as ChatResponseTurn2;
274
const parts: any[] = ((responseTurn as any).response.parts ?? (responseTurn as any).response._parts ?? (responseTurn as any).response);
275
const markdownParts = parts.filter(p => p instanceof ChatResponseMarkdownPart);
276
const allText = markdownParts.map(p => (p as any).value?.value ?? (p as any).value).join('');
277
// Top-level messages should be present
278
expect(allText).toContain('Top-level reply');
279
expect(allText).toContain('Final answer');
280
// Sub-agent messages should NOT be present
281
expect(allText).not.toContain('sub-agent thinking');
282
expect(allText).not.toContain('sub-agent result text');
283
});
284
285
it('populates modeInstructions2 on ChatRequestTurn2 from stored modeInstructions', () => {
286
const events: any[] = [
287
{ type: 'user.message', id: 'sdk-req-1', data: { content: 'Hello', attachments: [] } },
288
{ type: 'assistant.message', data: { content: 'Hi there', messageId: 'msg-1' } }
289
];
290
const getRequestId = (sdkRequestId: string): RequestIdDetails | undefined => {
291
if (sdkRequestId === 'sdk-req-1') {
292
return {
293
requestId: 'vscode-req-1',
294
toolIdEditMap: {},
295
modeInstructions: {
296
uri: 'file:///workspace/.github/agents/my-agent.agent.md',
297
name: 'my-agent',
298
content: 'You are a helpful agent',
299
metadata: { key: 'value' },
300
isBuiltin: false,
301
}
302
};
303
}
304
return undefined;
305
};
306
const turns = buildChatHistoryFromEvents('', undefined, events, getRequestId, delegationSummary, logger);
307
expect(turns).toHaveLength(2);
308
const requestTurn = turns[0] as ChatRequestTurn2;
309
expect(requestTurn.modeInstructions2).toBeDefined();
310
expect(requestTurn.modeInstructions2!.name).toBe('my-agent');
311
expect(requestTurn.modeInstructions2!.content).toBe('You are a helpful agent');
312
expect(requestTurn.modeInstructions2!.uri?.toString()).toBe('file:///workspace/.github/agents/my-agent.agent.md');
313
expect(requestTurn.modeInstructions2!.metadata).toEqual({ key: 'value' });
314
expect(requestTurn.modeInstructions2!.isBuiltin).toBe(false);
315
});
316
317
it('does not set modeInstructions2 when modeInstructions is undefined', () => {
318
const events: any[] = [
319
{ type: 'user.message', id: 'sdk-req-1', data: { content: 'Hello', attachments: [] } },
320
];
321
const getRequestId = (sdkRequestId: string): RequestIdDetails | undefined => {
322
if (sdkRequestId === 'sdk-req-1') {
323
return { requestId: 'vscode-req-1', toolIdEditMap: {} };
324
}
325
return undefined;
326
};
327
const turns = buildChatHistoryFromEvents('', undefined, events, getRequestId, delegationSummary, logger);
328
expect(turns).toHaveLength(1);
329
const requestTurn = turns[0] as ChatRequestTurn2;
330
expect(requestTurn.modeInstructions2).toBeUndefined();
331
});
332
333
it('handles modeInstructions without uri', () => {
334
const events: any[] = [
335
{ type: 'user.message', id: 'sdk-req-1', data: { content: 'Hello', attachments: [] } },
336
];
337
const getRequestId = (sdkRequestId: string): RequestIdDetails | undefined => {
338
if (sdkRequestId === 'sdk-req-1') {
339
return {
340
requestId: 'vscode-req-1',
341
toolIdEditMap: {},
342
modeInstructions: {
343
name: 'builtin-agent',
344
content: 'System instructions',
345
isBuiltin: true,
346
}
347
};
348
}
349
return undefined;
350
};
351
const turns = buildChatHistoryFromEvents('', undefined, events, getRequestId, delegationSummary, logger);
352
const requestTurn = turns[0] as ChatRequestTurn2;
353
expect(requestTurn.modeInstructions2).toBeDefined();
354
expect(requestTurn.modeInstructions2!.uri).toBeUndefined();
355
expect(requestTurn.modeInstructions2!.name).toBe('builtin-agent');
356
expect(requestTurn.modeInstructions2!.isBuiltin).toBe(true);
357
});
358
359
it('prefixes prompt with /autopilot when agentMode is autopilot', () => {
360
const events: any[] = [
361
{ type: 'user.message', data: { content: 'Fix the bug', attachments: [], agentMode: 'autopilot' } },
362
{ type: 'assistant.message', data: { content: 'Done' } }
363
];
364
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
365
expect(turns).toHaveLength(2);
366
const requestTurn = turns[0] as ChatRequestTurn2;
367
expect(requestTurn.prompt).toBe('/autopilot Fix the bug');
368
});
369
370
it('prefixes prompt with /plan when agentMode is plan', () => {
371
const events: any[] = [
372
{ type: 'user.message', data: { content: 'Create a plan', attachments: [], agentMode: 'plan' } },
373
{ type: 'assistant.message', data: { content: 'Here is the plan' } }
374
];
375
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
376
expect(turns).toHaveLength(2);
377
const requestTurn = turns[0] as ChatRequestTurn2;
378
expect(requestTurn.prompt).toBe('/plan Create a plan');
379
});
380
381
it('does not prefix prompt when agentMode is not set', () => {
382
const events: any[] = [
383
{ type: 'user.message', data: { content: 'Hello', attachments: [] } },
384
{ type: 'assistant.message', data: { content: 'Hi' } }
385
];
386
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
387
expect(turns).toHaveLength(2);
388
const requestTurn = turns[0] as ChatRequestTurn2;
389
expect(requestTurn.prompt).toBe('Hello');
390
});
391
392
it('does not prefix prompt when agentMode is an unknown value', () => {
393
const events: any[] = [
394
{ type: 'user.message', data: { content: 'Hello', attachments: [], agentMode: 'unknown-mode' } },
395
{ type: 'assistant.message', data: { content: 'Hi' } }
396
];
397
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
398
expect(turns).toHaveLength(2);
399
const requestTurn = turns[0] as ChatRequestTurn2;
400
expect(requestTurn.prompt).toBe('Hello');
401
});
402
});
403
404
describe('createCopilotCLIToolInvocation', () => {
405
it('returns undefined for report_intent', () => {
406
expect(createCopilotCLIToolInvocation({ toolName: 'report_intent', toolCallId: 'id', arguments: { intent: '' } })).toBeUndefined();
407
});
408
it('creates thinking progress part for think tool', () => {
409
const part = createCopilotCLIToolInvocation({ toolName: 'think', toolCallId: 'tid', arguments: { thought: 'Analyzing' } });
410
expect(part).toBeInstanceOf(ChatResponseThinkingProgressPart);
411
});
412
it('formats bash tool invocation with description', () => {
413
const part = createCopilotCLIToolInvocation({ toolName: 'bash', toolCallId: 'b1', arguments: { command: 'ls', description: 'List files' } });
414
expect(part).toBeInstanceOf(ChatToolInvocationPart);
415
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('List files');
416
});
417
it('formats str_replace_editor create', () => {
418
const part = createCopilotCLIToolInvocation({ toolName: 'str_replace_editor', toolCallId: 'e1', arguments: { command: 'create', path: '/tmp/x.ts' } });
419
expect(part).toBeInstanceOf(ChatToolInvocationPart);
420
const msg = getInvocationMessageText(part as ChatToolInvocationPart);
421
expect(msg).toMatch(/Creat/);
422
});
423
it.skip('formats show_file invocation with path', () => {
424
const part = createCopilotCLIToolInvocation({ toolName: 'show_file', toolCallId: 'sf1', arguments: { path: '/tmp/file.ts' } });
425
expect(part).toBeInstanceOf(ChatToolInvocationPart);
426
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toMatch(/Showing.*file\.ts/);
427
});
428
it.skip('formats show_file invocation with diff mode', () => {
429
const part = createCopilotCLIToolInvocation({ toolName: 'show_file', toolCallId: 'sf2', arguments: { path: '/tmp/file.ts', diff: true } });
430
expect(part).toBeInstanceOf(ChatToolInvocationPart);
431
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toMatch(/diff/i);
432
});
433
it.skip('formats show_file invocation with view_range', () => {
434
const part = createCopilotCLIToolInvocation({ toolName: 'show_file', toolCallId: 'sf3', arguments: { path: '/tmp/file.ts', view_range: [10, 20] } });
435
expect(part).toBeInstanceOf(ChatToolInvocationPart);
436
const msg = getInvocationMessageText(part as ChatToolInvocationPart);
437
expect(msg).toMatch(/10/);
438
expect(msg).toMatch(/20/);
439
});
440
it('formats propose_work invocation with title', () => {
441
const part = createCopilotCLIToolInvocation({ toolName: 'propose_work', toolCallId: 'pw1', arguments: { workType: 'code_change', workTitle: 'Refactor auth', workDescription: 'desc' } });
442
expect(part).toBeInstanceOf(ChatToolInvocationPart);
443
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('Refactor auth');
444
});
445
it('returns markdown part for task_complete invocation with summary', () => {
446
const part = createCopilotCLIToolInvocation({ toolName: 'task_complete', toolCallId: 'tc1', arguments: { summary: 'Fixed the bug' } });
447
expect(part).toBeInstanceOf(ChatResponseMarkdownPart);
448
expect((part as ChatResponseMarkdownPart).value.value).toContain('Fixed the bug');
449
});
450
it('returns undefined for task_complete invocation without summary', () => {
451
const part = createCopilotCLIToolInvocation({ toolName: 'task_complete', toolCallId: 'tc2', arguments: {} });
452
expect(part).toBeUndefined();
453
});
454
it('formats ask_user invocation with question', () => {
455
const part = createCopilotCLIToolInvocation({ toolName: 'ask_user', toolCallId: 'au1', arguments: { question: 'Which DB?' } });
456
expect(part).toBeInstanceOf(ChatToolInvocationPart);
457
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('Which DB?');
458
});
459
it('formats ask_user invocation with structured message', () => {
460
const part = createCopilotCLIToolInvocation({
461
toolName: 'ask_user',
462
toolCallId: 'au2',
463
arguments: {
464
message: 'Pick a deployment target',
465
requestedSchema: {
466
properties: {
467
target: { type: 'string', enum: ['staging', 'prod'] }
468
},
469
required: ['target']
470
}
471
}
472
});
473
expect(part).toBeInstanceOf(ChatToolInvocationPart);
474
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('Pick a deployment target');
475
});
476
it('formats skill invocation', () => {
477
const part = createCopilotCLIToolInvocation({ toolName: 'skill', toolCallId: 'sk1', arguments: { skill: 'pdf' } });
478
expect(part).toBeInstanceOf(ChatToolInvocationPart);
479
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('pdf');
480
});
481
it('formats task invocation with description', () => {
482
const part = createCopilotCLIToolInvocation({ toolName: 'task', toolCallId: 't1', arguments: { description: 'Run tests', prompt: 'Run all unit tests', agent_type: 'task' } });
483
expect(part).toBeInstanceOf(ChatToolInvocationPart);
484
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('Run tests');
485
});
486
it('formats read_agent invocation', () => {
487
const part = createCopilotCLIToolInvocation({ toolName: 'read_agent', toolCallId: 'ra1', arguments: { agent_id: 'agent-123' } });
488
expect(part).toBeInstanceOf(ChatToolInvocationPart);
489
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('agent-123');
490
});
491
it('formats exit_plan_mode invocation', () => {
492
const part = createCopilotCLIToolInvocation({ toolName: 'exit_plan_mode', toolCallId: 'ep1', arguments: { summary: 'Plan summary' } });
493
expect(part).toBeInstanceOf(ChatToolInvocationPart);
494
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toMatch(/plan/i);
495
});
496
it('formats sql invocation with description', () => {
497
const part = createCopilotCLIToolInvocation({ toolName: 'sql', toolCallId: 'sq1', arguments: { description: 'Query todos', query: 'SELECT * FROM todos' } });
498
expect(part).toBeInstanceOf(ChatToolInvocationPart);
499
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('Query todos');
500
});
501
it('formats lsp invocation with file', () => {
502
const part = createCopilotCLIToolInvocation({ toolName: 'lsp', toolCallId: 'lsp1', arguments: { operation: 'goToDefinition', file: '/tmp/app.ts', line: 10, character: 5 } });
503
expect(part).toBeInstanceOf(ChatToolInvocationPart);
504
const msg = getInvocationMessageText(part as ChatToolInvocationPart);
505
expect(msg).toContain('goToDefinition');
506
expect(msg).toMatch(/app\.ts/);
507
});
508
it('formats lsp invocation without file', () => {
509
const part = createCopilotCLIToolInvocation({ toolName: 'lsp', toolCallId: 'lsp2', arguments: { operation: 'workspaceSymbol', query: 'MyClass' } });
510
expect(part).toBeInstanceOf(ChatToolInvocationPart);
511
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('workspaceSymbol');
512
});
513
it('formats create_pull_request invocation', () => {
514
const part = createCopilotCLIToolInvocation({ toolName: 'create_pull_request', toolCallId: 'pr1', arguments: { title: 'Fix auth flow', description: 'Summary of changes', draft: false } });
515
expect(part).toBeInstanceOf(ChatToolInvocationPart);
516
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('Fix auth flow');
517
expect((part as ChatToolInvocationPart).originMessage).toContain('Summary of changes');
518
});
519
it('formats search_code_subagent invocation', () => {
520
const part = createCopilotCLIToolInvocation({ toolName: 'search_code_subagent', toolCallId: 'sc1', arguments: { query: 'find auth middleware' } });
521
expect(part).toBeInstanceOf(ChatToolInvocationPart);
522
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('find auth middleware');
523
});
524
it('formats store_memory invocation', () => {
525
const part = createCopilotCLIToolInvocation({ toolName: 'store_memory', toolCallId: 'sm1', arguments: { subject: 'naming', fact: 'Use camelCase', citations: 'src/foo.ts:1', reason: 'consistency', category: 'general' } });
526
expect(part).toBeInstanceOf(ChatToolInvocationPart);
527
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('naming');
528
});
529
it('creates invocation for fetch_copilot_cli_documentation', () => {
530
const part = createCopilotCLIToolInvocation({ toolName: 'fetch_copilot_cli_documentation', toolCallId: 'fd1', arguments: {} });
531
expect(part).toBeInstanceOf(ChatToolInvocationPart);
532
});
533
it('creates invocation for list_agents', () => {
534
const part = createCopilotCLIToolInvocation({ toolName: 'list_agents', toolCallId: 'la1', arguments: {} });
535
expect(part).toBeInstanceOf(ChatToolInvocationPart);
536
});
537
it('creates invocation for list_bash', () => {
538
const part = createCopilotCLIToolInvocation({ toolName: 'list_bash', toolCallId: 'lb1', arguments: {} });
539
expect(part).toBeInstanceOf(ChatToolInvocationPart);
540
});
541
it('creates invocation for list_powershell', () => {
542
const part = createCopilotCLIToolInvocation({ toolName: 'list_powershell', toolCallId: 'lp1', arguments: {} });
543
expect(part).toBeInstanceOf(ChatToolInvocationPart);
544
});
545
it('creates invocation for gh-advisory-database', () => {
546
const part = createCopilotCLIToolInvocation({ toolName: 'gh-advisory-database', toolCallId: 'gh1', arguments: { dependencies: [{ name: 'lodash', version: '4.17.0', ecosystem: 'npm' }] } });
547
expect(part).toBeInstanceOf(ChatToolInvocationPart);
548
});
549
it('creates invocation for parallel_validation', () => {
550
const part = createCopilotCLIToolInvocation({ toolName: 'parallel_validation', toolCallId: 'pv1', arguments: {} });
551
expect(part).toBeInstanceOf(ChatToolInvocationPart);
552
});
553
it('formats apply_patch invocation', () => {
554
const part = createCopilotCLIToolInvocation({ toolName: 'apply_patch', toolCallId: 'ap1', arguments: { input: '*** Begin Patch\n*** End Patch' } });
555
expect(part).toBeInstanceOf(ChatToolInvocationPart);
556
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toMatch(/patch/i);
557
});
558
it('formats write_agent invocation with agent_id', () => {
559
const part = createCopilotCLIToolInvocation({ toolName: 'write_agent', toolCallId: 'wa1', arguments: { agent_id: 'agent-42', message: 'Hello agent' } });
560
expect(part).toBeInstanceOf(ChatToolInvocationPart);
561
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('agent-42');
562
});
563
it('creates invocation for mcp_reload', () => {
564
const part = createCopilotCLIToolInvocation({ toolName: 'mcp_reload', toolCallId: 'mr1', arguments: {} });
565
expect(part).toBeInstanceOf(ChatToolInvocationPart);
566
});
567
it('formats mcp_validate invocation with path', () => {
568
const part = createCopilotCLIToolInvocation({ toolName: 'mcp_validate', toolCallId: 'mv1', arguments: { path: '/home/user/.copilot/config/mcp-config.json' } });
569
expect(part).toBeInstanceOf(ChatToolInvocationPart);
570
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toMatch(/mcp-config\.json/i);
571
});
572
it('formats tool_search_tool_regex invocation with pattern', () => {
573
const part = createCopilotCLIToolInvocation({ toolName: 'tool_search_tool_regex', toolCallId: 'ts1', arguments: { pattern: 'search.*file' } });
574
expect(part).toBeInstanceOf(ChatToolInvocationPart);
575
expect(getInvocationMessageText(part as ChatToolInvocationPart)).toContain('search.*file');
576
});
577
it('creates invocation for codeql_checker', () => {
578
const part = createCopilotCLIToolInvocation({ toolName: 'codeql_checker', toolCallId: 'cq1', arguments: {} });
579
expect(part).toBeInstanceOf(ChatToolInvocationPart);
580
});
581
});
582
583
describe('process tool execution lifecycle', () => {
584
it('marks tool invocation complete and confirmed on success', () => {
585
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
586
const startEvent: any = { type: 'tool.execution_start', data: { toolName: 'bash', toolCallId: 'bash-1', arguments: { command: 'echo hi' } } };
587
const part = processToolExecutionStart(startEvent, pending);
588
expect(part).toBeInstanceOf(ChatToolInvocationPart);
589
const completeEvent: any = { type: 'tool.execution_complete', data: { toolName: 'bash', toolCallId: 'bash-1', success: true } };
590
const [completed,] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
591
expect(completed.isComplete).toBe(true);
592
expect(completed.isError).toBe(false);
593
expect(completed.isConfirmed).toBe(true);
594
});
595
it('marks tool invocation error and unconfirmed when denied', () => {
596
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
597
processToolExecutionStart({ type: 'tool.execution_start', data: { toolName: 'bash', toolCallId: 'bash-2', arguments: { command: 'rm *' } } } as any, pending);
598
const completeEvent: any = { type: 'tool.execution_complete', data: { toolName: 'bash', toolCallId: 'bash-2', success: false, error: { message: 'Denied', code: 'denied' } } };
599
const [completed,] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
600
expect(completed.isComplete).toBe(true);
601
expect(completed.isError).toBe(true);
602
expect(completed.isConfirmed).toBe(false);
603
expect(getInvocationMessageText(completed)).toContain('Denied');
604
});
605
606
it('adds task_complete markdown start event to pending invocations', () => {
607
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
608
const part = processToolExecutionStart({
609
type: 'tool.execution_start',
610
data: { toolName: 'task_complete', toolCallId: 'tc-start', arguments: { summary: 'Task done.' } }
611
} as any, pending);
612
613
expect(part).toBeInstanceOf(ChatResponseMarkdownPart);
614
expect((part as ChatResponseMarkdownPart).value.value).toContain('Task done.');
615
expect(pending.size).toBe(1);
616
});
617
618
it('returns task_complete markdown part on completion', () => {
619
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
620
processToolExecutionStart({
621
type: 'tool.execution_start',
622
data: { toolName: 'task_complete', toolCallId: 'tc-complete', arguments: { summary: 'Done.' } }
623
} as any, pending);
624
625
const completed = processToolExecutionComplete({
626
type: 'tool.execution_complete',
627
data: { toolName: 'task_complete', toolCallId: 'tc-complete', success: true }
628
} as any, pending, logger);
629
630
expect(completed).toBeDefined();
631
const [part] = completed!;
632
expect(part).toBeInstanceOf(ChatResponseMarkdownPart);
633
expect((part as ChatResponseMarkdownPart).value.value).toContain('Done.');
634
});
635
});
636
637
describe('MCP tool result handling', () => {
638
it('handles MCP tool with text content in result.contents', () => {
639
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
640
const startEvent: any = {
641
type: 'tool.execution_start',
642
data: { toolName: 'custom_mcp_tool', toolCallId: 'mcp-1', mcpServerName: 'test-server', mcpToolName: 'my-tool', arguments: { foo: 'bar' } }
643
};
644
processToolExecutionStart(startEvent, pending);
645
646
const completeEvent: any = {
647
type: 'tool.execution_complete',
648
data: {
649
toolName: 'custom_mcp_tool',
650
toolCallId: 'mcp-1',
651
mcpServerName: 'test-server',
652
mcpToolName: 'my-tool',
653
success: true,
654
result: {
655
contents: [
656
{ type: 'text', text: 'Hello from MCP tool' }
657
]
658
}
659
}
660
};
661
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
662
expect(completed.isComplete).toBe(true);
663
expect(completed.toolSpecificData).toBeDefined();
664
const mcpData = completed.toolSpecificData as any;
665
expect(mcpData.input).toContain('foo');
666
expect(mcpData.output).toHaveLength(1);
667
});
668
669
it('handles MCP tool with empty result.contents', () => {
670
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
671
processToolExecutionStart({
672
type: 'tool.execution_start',
673
data: { toolName: 'empty_mcp', toolCallId: 'mcp-2', mcpServerName: 'server', mcpToolName: 'tool', arguments: {} }
674
} as any, pending);
675
676
const completeEvent: any = {
677
type: 'tool.execution_complete',
678
data: {
679
toolName: 'empty_mcp',
680
toolCallId: 'mcp-2',
681
mcpServerName: 'server',
682
mcpToolName: 'tool',
683
success: true,
684
result: { contents: [] }
685
}
686
};
687
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
688
expect(completed.toolSpecificData).toBeUndefined();
689
});
690
691
it('handles MCP tool with undefined result.contents', () => {
692
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
693
processToolExecutionStart({
694
type: 'tool.execution_start',
695
data: { toolName: 'no_contents_mcp', toolCallId: 'mcp-3', mcpServerName: 'server', mcpToolName: 'tool', arguments: {} }
696
} as any, pending);
697
698
const completeEvent: any = {
699
type: 'tool.execution_complete',
700
data: {
701
toolName: 'no_contents_mcp',
702
toolCallId: 'mcp-3',
703
mcpServerName: 'server',
704
mcpToolName: 'tool',
705
success: true,
706
result: {}
707
}
708
};
709
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
710
expect(completed.toolSpecificData).toBeUndefined();
711
});
712
});
713
714
describe('glob/grep tool with terminal content type', () => {
715
it('parses files from result.contents with terminal type', () => {
716
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
717
processToolExecutionStart({
718
type: 'tool.execution_start',
719
data: { toolName: 'glob', toolCallId: 'glob-1', arguments: { pattern: '*.ts' } }
720
} as any, pending);
721
722
const completeEvent: any = {
723
type: 'tool.execution_complete',
724
data: {
725
toolName: 'glob',
726
toolCallId: 'glob-1',
727
success: true,
728
result: {
729
contents: [
730
{ type: 'terminal', text: './file1.ts\n./file2.ts\n./file3.ts' }
731
]
732
}
733
}
734
};
735
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
736
expect(completed.pastTenseMessage).toContain('3 results');
737
expect(completed.toolSpecificData).toBeDefined();
738
const data = completed.toolSpecificData as any;
739
expect(data.values).toHaveLength(3);
740
});
741
742
it('handles empty terminal text as no matches', () => {
743
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
744
processToolExecutionStart({
745
type: 'tool.execution_start',
746
data: { toolName: 'grep', toolCallId: 'grep-1', arguments: { pattern: 'nonexistent' } }
747
} as any, pending);
748
749
const completeEvent: any = {
750
type: 'tool.execution_complete',
751
data: {
752
toolName: 'grep',
753
toolCallId: 'grep-1',
754
success: true,
755
result: {
756
contents: [
757
{ type: 'terminal', text: '' }
758
]
759
}
760
}
761
};
762
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
763
expect(completed.pastTenseMessage).toContain('.');
764
expect(completed.pastTenseMessage).not.toContain('result');
765
const data = completed.toolSpecificData as any;
766
expect(data.values).toHaveLength(0);
767
});
768
769
it('handles whitespace-only terminal text as no matches', () => {
770
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
771
processToolExecutionStart({
772
type: 'tool.execution_start',
773
data: { toolName: 'rg', toolCallId: 'rg-1', arguments: { pattern: 'missing' } }
774
} as any, pending);
775
776
const completeEvent: any = {
777
type: 'tool.execution_complete',
778
data: {
779
toolName: 'rg',
780
toolCallId: 'rg-1',
781
success: true,
782
result: {
783
contents: [
784
{ type: 'terminal', text: ' \n\t\n ' }
785
]
786
}
787
}
788
};
789
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
790
const data = completed.toolSpecificData as any;
791
expect(data.values).toHaveLength(0);
792
});
793
794
it('falls back to result.content when contents is not present', () => {
795
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
796
processToolExecutionStart({
797
type: 'tool.execution_start',
798
data: { toolName: 'glob', toolCallId: 'glob-2', arguments: { pattern: '*.js' } }
799
} as any, pending);
800
801
const completeEvent: any = {
802
type: 'tool.execution_complete',
803
data: {
804
toolName: 'glob',
805
toolCallId: 'glob-2',
806
success: true,
807
result: {
808
content: './app.js\n./index.js'
809
}
810
}
811
};
812
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
813
expect(completed.pastTenseMessage).toContain('2 results');
814
const data = completed.toolSpecificData as any;
815
expect(data.values).toHaveLength(2);
816
});
817
818
it('detects no matches message in legacy result.content format', () => {
819
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
820
processToolExecutionStart({
821
type: 'tool.execution_start',
822
data: { toolName: 'grep', toolCallId: 'grep-2', arguments: { pattern: 'xyz' } }
823
} as any, pending);
824
825
const completeEvent: any = {
826
type: 'tool.execution_complete',
827
data: {
828
toolName: 'grep',
829
toolCallId: 'grep-2',
830
success: true,
831
result: {
832
content: 'No matches found'
833
}
834
}
835
};
836
const [completed] = processToolExecutionComplete(completeEvent, pending, logger)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
837
const data = completed.toolSpecificData as any;
838
expect(data.values).toHaveLength(0);
839
});
840
});
841
842
describe('extractCdPrefix', () => {
843
it('extracts cd prefix from bash command', () => {
844
const result = extractCdPrefix('cd /home/user/project && npm run test', false);
845
expect(result).toEqual({ directory: '/home/user/project', command: 'npm run test' });
846
});
847
848
it('returns undefined for bash command without cd prefix', () => {
849
expect(extractCdPrefix('npm run test', false)).toBeUndefined();
850
});
851
852
it('strips surrounding quotes from directory path', () => {
853
const result = extractCdPrefix('cd "/path/with spaces" && ls', false);
854
expect(result).toEqual({ directory: '/path/with spaces', command: 'ls' });
855
});
856
857
it('extracts cd prefix from powershell command with &&', () => {
858
const result = extractCdPrefix('cd /d C:\\project && npm start', true);
859
expect(result).toEqual({ directory: 'C:\\project', command: 'npm start' });
860
});
861
862
it('extracts Set-Location prefix from powershell command', () => {
863
const result = extractCdPrefix('Set-Location C:\\project; npm start', true);
864
expect(result).toEqual({ directory: 'C:\\project', command: 'npm start' });
865
});
866
867
it('extracts Set-Location -Path prefix from powershell command', () => {
868
const result = extractCdPrefix('Set-Location -Path C:\\project && npm start', true);
869
expect(result).toEqual({ directory: 'C:\\project', command: 'npm start' });
870
});
871
872
it('returns undefined for command with only cd and no suffix', () => {
873
expect(extractCdPrefix('cd /home/user', false)).toBeUndefined();
874
});
875
});
876
877
describe('formatShellInvocation with presentationOverrides', () => {
878
it('sets presentationOverrides when cd prefix matches workingDirectory', () => {
879
const workingDirectory = URI.file('/home/user/project');
880
const part = createCopilotCLIToolInvocation({
881
toolName: 'bash',
882
toolCallId: 'b-cd-1',
883
arguments: { command: 'cd /home/user/project && npm run unit', description: 'Run tests' }
884
}, undefined, workingDirectory) as ChatToolInvocationPart;
885
expect(part).toBeInstanceOf(ChatToolInvocationPart);
886
const data = part.toolSpecificData as any;
887
expect(data.commandLine.original).toBe('npm run unit');
888
expect(data.presentationOverrides).toEqual({ commandLine: 'npm run unit' });
889
});
890
891
it('does not set presentationOverrides when cd prefix does not match workingDirectory', () => {
892
const workingDirectory = URI.file('/other/directory');
893
const part = createCopilotCLIToolInvocation({
894
toolName: 'bash',
895
toolCallId: 'b-cd-mismatch',
896
arguments: { command: 'cd /home/user/project && npm run unit', description: 'Run tests' }
897
}, undefined, workingDirectory) as ChatToolInvocationPart;
898
const data = part.toolSpecificData as any;
899
expect(data.commandLine.original).toBe('cd /home/user/project && npm run unit');
900
expect(data.presentationOverrides).toBeUndefined();
901
});
902
903
it('does not set presentationOverrides when no workingDirectory provided', () => {
904
const part = createCopilotCLIToolInvocation({
905
toolName: 'bash',
906
toolCallId: 'b-cd-nowd',
907
arguments: { command: 'cd /home/user/project && npm run unit', description: 'Run tests' }
908
}) as ChatToolInvocationPart;
909
const data = part.toolSpecificData as any;
910
expect(data.commandLine.original).toBe('cd /home/user/project && npm run unit');
911
expect(data.presentationOverrides).toBeUndefined();
912
});
913
914
it('does not set presentationOverrides when no cd prefix', () => {
915
const workingDirectory = URI.file('/home/user/project');
916
const part = createCopilotCLIToolInvocation({
917
toolName: 'bash',
918
toolCallId: 'b-nocd-1',
919
arguments: { command: 'npm run unit', description: 'Run tests' }
920
}, undefined, workingDirectory) as ChatToolInvocationPart;
921
const data = part.toolSpecificData as any;
922
expect(data.commandLine.original).toBe('npm run unit');
923
expect(data.presentationOverrides).toBeUndefined();
924
});
925
926
it('sets presentationOverrides on completed shell invocation when cd matches workingDirectory', () => {
927
const workingDirectory = URI.file('/workspace');
928
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
929
processToolExecutionStart({
930
type: 'tool.execution_start',
931
data: { toolName: 'bash', toolCallId: 'b-cd-2', arguments: { command: 'cd /workspace && make build', description: 'Build' } }
932
} as any, pending, workingDirectory);
933
934
const [completed] = processToolExecutionComplete({
935
type: 'tool.execution_complete',
936
data: {
937
toolName: 'bash',
938
toolCallId: 'b-cd-2',
939
success: true,
940
result: { content: 'build output\n<exited with exit code 0>' }
941
}
942
} as any, pending, logger, workingDirectory)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
943
944
const data = completed.toolSpecificData as any;
945
expect(data.commandLine.original).toBe('make build');
946
expect(data.presentationOverrides).toEqual({ commandLine: 'make build' });
947
expect(data.state.exitCode).toBe(0);
948
});
949
950
it('does not set presentationOverrides on completed shell invocation when cd does not match workingDirectory', () => {
951
const workingDirectory = URI.file('/other');
952
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseThinkingProgressPart, toolData: ToolCall, parentToolCallId: string | undefined]>();
953
processToolExecutionStart({
954
type: 'tool.execution_start',
955
data: { toolName: 'bash', toolCallId: 'b-cd-3', arguments: { command: 'cd /workspace && make build', description: 'Build' } }
956
} as any, pending, workingDirectory);
957
958
const [completed] = processToolExecutionComplete({
959
type: 'tool.execution_complete',
960
data: {
961
toolName: 'bash',
962
toolCallId: 'b-cd-3',
963
success: true,
964
result: { content: '<exited with exit code 0>' }
965
}
966
} as any, pending, logger, workingDirectory)! as [ChatToolInvocationPart, ToolCall, parentToolCallId: string | undefined];
967
968
const data = completed.toolSpecificData as any;
969
expect(data.presentationOverrides).toBeUndefined();
970
});
971
});
972
973
describe('isCopilotCLIToolThatCouldRequirePermissions', () => {
974
const makeEvent = (data: Record<string, unknown>) => ({ type: 'tool.execution_start', data } as any);
975
976
it('returns true for edit tool calls (create, edit)', () => {
977
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'create', toolCallId: '1', arguments: { path: '/tmp/a' } }))).toBe(true);
978
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'edit', toolCallId: '2', arguments: { path: '/tmp/b' } }))).toBe(true);
979
});
980
981
it('returns true for str_replace_editor non-view commands', () => {
982
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'str_replace_editor', toolCallId: '3', arguments: { command: 'str_replace', path: '/tmp/a' } }))).toBe(true);
983
});
984
985
it('returns true for bash and powershell', () => {
986
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'bash', toolCallId: '4', arguments: { command: 'echo hi' } }))).toBe(true);
987
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'powershell', toolCallId: '5', arguments: { command: 'echo hi' } }))).toBe(true);
988
});
989
990
it('returns true for view tool', () => {
991
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'view', toolCallId: '6', arguments: { path: '/tmp/a' } }))).toBe(true);
992
});
993
994
it('returns false for MCP tools even if tool name matches', () => {
995
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'bash', toolCallId: '7', mcpServerName: 'my-server', arguments: { command: 'echo' } }))).toBe(false);
996
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'view', toolCallId: '8', mcpServerName: 'my-server', arguments: { path: '/tmp' } }))).toBe(false);
997
});
998
999
it('returns false for non-permission tools like think, report_intent, glob', () => {
1000
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'think', toolCallId: '9', arguments: { thought: 'hmm' } }))).toBe(false);
1001
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'report_intent', toolCallId: '10', arguments: {} }))).toBe(false);
1002
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'glob', toolCallId: '11', arguments: { pattern: '*.ts' } }))).toBe(false);
1003
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'grep', toolCallId: '12', arguments: { pattern: 'foo' } }))).toBe(false);
1004
});
1005
1006
it('returns false for str_replace_editor view command (not an edit)', () => {
1007
expect(isCopilotCLIToolThatCouldRequirePermissions(makeEvent({ toolName: 'str_replace_editor', toolCallId: '13', arguments: { command: 'view', path: '/tmp/a' } }))).toBe(false);
1008
});
1009
});
1010
1011
describe('integration edge cases', () => {
1012
it('ignores report_intent events inside history build', () => {
1013
const events: any[] = [
1014
{ type: 'user.message', data: { content: 'Hi', attachments: [] } },
1015
{ type: 'tool.execution_start', data: { toolName: 'report_intent', toolCallId: 'ri-1', arguments: {} } },
1016
{ type: 'tool.execution_complete', data: { toolName: 'report_intent', toolCallId: 'ri-1', success: true } }
1017
];
1018
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1019
expect(turns).toHaveLength(1); // Only user turn, no response parts because no assistant/tool parts were added
1020
});
1021
1022
it('handles multiple user messages flushing response parts correctly', () => {
1023
const events: any[] = [
1024
{ type: 'assistant.message', data: { content: 'Hello' } },
1025
{ type: 'user.message', data: { content: 'Follow up', attachments: [] } },
1026
{ type: 'assistant.message', data: { content: 'Response 2' } }
1027
];
1028
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1029
// Expect: first assistant message buffered until user msg -> becomes response turn, then user request, then second assistant -> another response
1030
expect(turns.filter(t => t instanceof ChatResponseTurn2)).toHaveLength(2);
1031
expect(turns.filter(t => t instanceof ChatRequestTurn2)).toHaveLength(1);
1032
});
1033
1034
it('creates markdown part only when cleaned content not empty after stripping PR metadata', () => {
1035
const events: any[] = [
1036
{ type: 'assistant.message', data: { content: '<pr_metadata uri="u" title="t" description="d" author="a" linkTag="l"/>' } }
1037
];
1038
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1039
// Single response turn with ONLY PR part (no markdown text)
1040
const responseTurns = turns.filter(t => t instanceof ChatResponseTurn2) as ChatResponseTurn2[];
1041
expect(responseTurns).toHaveLength(1);
1042
const responseParts: any = (responseTurns[0] as any).response;
1043
const parts: any[] = (responseParts.parts ?? responseParts._parts ?? responseParts);
1044
const prCount = parts.filter(p => p instanceof ChatResponsePullRequestPart).length;
1045
const mdCount = parts.filter(p => p instanceof ChatResponseMarkdownPart).length;
1046
expect(prCount).toBe(1);
1047
expect(mdCount).toBe(0);
1048
});
1049
});
1050
1051
describe('enrichToolInvocationWithSubagentMetadata', () => {
1052
it('enriches task tool invocation with subagent display name and description', () => {
1053
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseMarkdownPart | ChatResponseThinkingProgressPart, ToolCall, string | undefined]>();
1054
processToolExecutionStart({
1055
type: 'tool.execution_start',
1056
data: {
1057
toolCallId: 'task-1',
1058
toolName: 'task',
1059
arguments: { description: 'Review code', agent_type: 'reviewer', prompt: 'Check for bugs' }
1060
}
1061
} as any, pending);
1062
1063
enrichToolInvocationWithSubagentMetadata('task-1', 'Code Review Agent', 'Reviews code for bugs', pending);
1064
1065
const [part] = pending.get('task-1')!;
1066
expect(part).toBeInstanceOf(ChatToolInvocationPart);
1067
const toolPart = part as ChatToolInvocationPart;
1068
const data = toolPart.toolSpecificData as any;
1069
expect(data.agentName).toBe('Code Review Agent');
1070
expect(data.description).toBe('Reviews code for bugs');
1071
});
1072
1073
it('does not crash when toolCallId is not found in pending invocations', () => {
1074
const pending = new Map<string, [ChatToolInvocationPart | ChatResponseMarkdownPart | ChatResponseThinkingProgressPart, ToolCall, string | undefined]>();
1075
// Should not throw
1076
enrichToolInvocationWithSubagentMetadata('nonexistent', 'Agent', 'Desc', pending);
1077
});
1078
});
1079
1080
describe('buildChatHistoryFromEvents with subagent events', () => {
1081
it('enriches task tool with subagent.started metadata during history rebuild', () => {
1082
const events: any[] = [
1083
{ type: 'user.message', id: 'u1', data: { content: 'Do a review', attachments: [] } },
1084
{ type: 'tool.execution_start', data: { toolCallId: 'task-1', toolName: 'task', arguments: { description: 'Review', agent_type: 'reviewer', prompt: 'Check' } } },
1085
{ type: 'subagent.started', data: { toolCallId: 'task-1', agentName: 'reviewer', agentDisplayName: 'Code Review Agent', agentDescription: 'Reviews code carefully' } },
1086
{ type: 'tool.execution_complete', data: { toolCallId: 'task-1', success: true, result: { content: 'All good' } } },
1087
{ type: 'subagent.completed', data: { toolCallId: 'task-1', agentName: 'reviewer', agentDisplayName: 'Code Review Agent' } },
1088
];
1089
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1090
const responseTurns = turns.filter(t => t instanceof ChatResponseTurn2) as ChatResponseTurn2[];
1091
expect(responseTurns).toHaveLength(1);
1092
const parts: any[] = ((responseTurns[0] as any).response.parts ?? (responseTurns[0] as any).response._parts ?? (responseTurns[0] as any).response);
1093
const toolParts = parts.filter((p: any) => p instanceof ChatToolInvocationPart);
1094
expect(toolParts).toHaveLength(1);
1095
const toolPart = toolParts[0] as ChatToolInvocationPart;
1096
const data = toolPart.toolSpecificData as any;
1097
expect(data.agentName).toBe('Code Review Agent');
1098
expect(data.description).toBe('Reviews code carefully');
1099
});
1100
1101
it('sets subAgentInvocationId on nested tool calls within a subagent', () => {
1102
const events: any[] = [
1103
{ type: 'user.message', id: 'u1', data: { content: 'Review my code', attachments: [] } },
1104
// Top-level task tool starts a subagent
1105
{ type: 'tool.execution_start', data: { toolCallId: 'task-top', toolName: 'task', arguments: { description: 'Review', agent_type: 'reviewer', prompt: 'Check' } } },
1106
{ type: 'subagent.started', data: { toolCallId: 'task-top', agentName: 'reviewer', agentDisplayName: 'Reviewer', agentDescription: 'desc' } },
1107
// Child tool inside the subagent
1108
{ type: 'tool.execution_start', data: { toolCallId: 'read-1', toolName: 'view', parentToolCallId: 'task-top', arguments: { path: '/tmp/file.ts' } } },
1109
{ type: 'tool.execution_complete', data: { toolCallId: 'read-1', success: true, result: { content: 'file content' } } },
1110
// Nested task tool (subagent invokes another subagent)
1111
{ type: 'tool.execution_start', data: { toolCallId: 'task-nested', toolName: 'task', parentToolCallId: 'task-top', arguments: { description: 'Explore', agent_type: 'explore', prompt: 'Search' } } },
1112
{ type: 'subagent.started', data: { toolCallId: 'task-nested', agentName: 'explore', agentDisplayName: 'Explore Agent', agentDescription: 'Explores code' } },
1113
{ type: 'tool.execution_complete', data: { toolCallId: 'task-nested', success: true, result: { content: 'found it' } } },
1114
{ type: 'subagent.completed', data: { toolCallId: 'task-nested', agentName: 'explore', agentDisplayName: 'Explore Agent' } },
1115
{ type: 'tool.execution_complete', data: { toolCallId: 'task-top', success: true, result: { content: 'review done' } } },
1116
{ type: 'subagent.completed', data: { toolCallId: 'task-top', agentName: 'reviewer', agentDisplayName: 'Reviewer' } },
1117
];
1118
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1119
const responseTurns = turns.filter(t => t instanceof ChatResponseTurn2) as ChatResponseTurn2[];
1120
expect(responseTurns).toHaveLength(1);
1121
const parts: any[] = ((responseTurns[0] as any).response.parts ?? (responseTurns[0] as any).response._parts ?? (responseTurns[0] as any).response);
1122
const toolParts = parts.filter((p: any) => p instanceof ChatToolInvocationPart) as ChatToolInvocationPart[];
1123
// Should have 3 tool parts: task-top, read-1, task-nested
1124
expect(toolParts).toHaveLength(3);
1125
1126
// Child read tool should have subAgentInvocationId pointing to root
1127
const readPart = toolParts.find(p => p.toolCallId === 'read-1')!;
1128
expect(readPart.subAgentInvocationId).toBe('task-top');
1129
1130
// Nested task tool should also resolve to root, not its immediate parent
1131
const nestedTaskPart = toolParts.find(p => p.toolCallId === 'task-nested')!;
1132
expect(nestedTaskPart.subAgentInvocationId).toBe('task-top');
1133
// Nested task should have ChatSubagentToolInvocationData cleared
1134
// so it doesn't create its own subagent container
1135
expect(nestedTaskPart.toolSpecificData).toBeUndefined();
1136
});
1137
1138
it('gracefully handles subagent.failed events', () => {
1139
const events: any[] = [
1140
{ type: 'user.message', id: 'u1', data: { content: 'Do something', attachments: [] } },
1141
{ type: 'tool.execution_start', data: { toolCallId: 'task-1', toolName: 'task', arguments: { description: 'Review', agent_type: 'reviewer', prompt: 'Check' } } },
1142
{ type: 'subagent.started', data: { toolCallId: 'task-1', agentName: 'reviewer', agentDisplayName: 'Reviewer', agentDescription: 'desc' } },
1143
{ type: 'subagent.failed', data: { toolCallId: 'task-1', agentName: 'reviewer', agentDisplayName: 'Reviewer', error: { message: 'timeout' } } },
1144
{ type: 'tool.execution_complete', data: { toolCallId: 'task-1', success: false, error: { code: 'timeout', message: 'Agent timed out' } } },
1145
];
1146
// Should not throw
1147
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1148
expect(turns).toHaveLength(2); // request + response
1149
});
1150
1151
it('resolves deeply nested tools to the root ancestor subagent', () => {
1152
const events: any[] = [
1153
{ type: 'user.message', id: 'u1', data: { content: 'Go', attachments: [] } },
1154
// Level 0: top-level task
1155
{ type: 'tool.execution_start', data: { toolCallId: 'L0', toolName: 'task', arguments: { description: 'Top', agent_type: 'top', prompt: 'p' } } },
1156
// Level 1: nested task inside L0
1157
{ type: 'tool.execution_start', data: { toolCallId: 'L1', toolName: 'task', parentToolCallId: 'L0', arguments: { description: 'Mid', agent_type: 'mid', prompt: 'p' } } },
1158
// Level 2: nested task inside L1
1159
{ type: 'tool.execution_start', data: { toolCallId: 'L2', toolName: 'task', parentToolCallId: 'L1', arguments: { description: 'Deep', agent_type: 'deep', prompt: 'p' } } },
1160
// Level 3: regular tool inside L2
1161
{ type: 'tool.execution_start', data: { toolCallId: 'grep-1', toolName: 'grep', parentToolCallId: 'L2', arguments: { pattern: 'foo' } } },
1162
{ type: 'tool.execution_complete', data: { toolCallId: 'grep-1', success: true, result: { content: 'found' } } },
1163
{ type: 'tool.execution_complete', data: { toolCallId: 'L2', success: true, result: { content: 'done' } } },
1164
{ type: 'tool.execution_complete', data: { toolCallId: 'L1', success: true, result: { content: 'done' } } },
1165
{ type: 'tool.execution_complete', data: { toolCallId: 'L0', success: true, result: { content: 'done' } } },
1166
];
1167
const turns = buildChatHistoryFromEvents('', undefined, events, getVSCodeRequestId, delegationSummary, logger);
1168
const responseTurns = turns.filter(t => t instanceof ChatResponseTurn2) as ChatResponseTurn2[];
1169
const parts: any[] = ((responseTurns[0] as any).response.parts ?? (responseTurns[0] as any).response._parts ?? (responseTurns[0] as any).response);
1170
const toolParts = parts.filter((p: any) => p instanceof ChatToolInvocationPart) as ChatToolInvocationPart[];
1171
1172
// Top-level task has no subAgentInvocationId (it IS the parent container)
1173
const l0 = toolParts.find(p => p.toolCallId === 'L0')!;
1174
expect(l0.subAgentInvocationId).toBeUndefined();
1175
// Top-level task keeps its ChatSubagentToolInvocationData
1176
expect(l0.toolSpecificData).toBeDefined();
1177
1178
// L1 nested task resolves to root L0
1179
const l1 = toolParts.find(p => p.toolCallId === 'L1')!;
1180
expect(l1.subAgentInvocationId).toBe('L0');
1181
expect(l1.toolSpecificData).toBeUndefined();
1182
1183
// L2 nested task also resolves to root L0 (not L1)
1184
const l2 = toolParts.find(p => p.toolCallId === 'L2')!;
1185
expect(l2.subAgentInvocationId).toBe('L0');
1186
expect(l2.toolSpecificData).toBeUndefined();
1187
1188
// grep tool at level 3 also resolves to root L0
1189
const grep = toolParts.find(p => p.toolCallId === 'grep-1')!;
1190
expect(grep.subAgentInvocationId).toBe('L0');
1191
});
1192
});
1193
1194
describe('isTodoRelatedSqlQuery', () => {
1195
it('returns true for INSERT into todos', () => {
1196
expect(isTodoRelatedSqlQuery('INSERT INTO todos (title, status) VALUES (\'task\', \'pending\')')).toBe(true);
1197
});
1198
1199
it('returns true for UPDATE todos', () => {
1200
expect(isTodoRelatedSqlQuery('UPDATE todos SET status = \'done\' WHERE id = 1')).toBe(true);
1201
});
1202
1203
it('returns true for DELETE from todos', () => {
1204
expect(isTodoRelatedSqlQuery('DELETE FROM todos WHERE id = 1')).toBe(true);
1205
});
1206
1207
it('returns true for CREATE TABLE todos', () => {
1208
expect(isTodoRelatedSqlQuery('CREATE TABLE todos (id INTEGER PRIMARY KEY, title TEXT)')).toBe(true);
1209
});
1210
1211
it('returns true for queries targeting todo_deps table', () => {
1212
expect(isTodoRelatedSqlQuery('INSERT INTO todo_deps (todo_id, dep_id) VALUES (1, 2)')).toBe(true);
1213
});
1214
1215
it('returns false for SELECT-only queries on todos', () => {
1216
expect(isTodoRelatedSqlQuery('SELECT * FROM todos')).toBe(false);
1217
});
1218
1219
it('returns false for queries not targeting todos or todo_deps', () => {
1220
expect(isTodoRelatedSqlQuery('INSERT INTO tasks (title) VALUES (\'task\')')).toBe(false);
1221
});
1222
1223
it('returns false for empty query', () => {
1224
expect(isTodoRelatedSqlQuery('')).toBe(false);
1225
});
1226
1227
it('is case insensitive', () => {
1228
expect(isTodoRelatedSqlQuery('INSERT INTO TODOS (title) VALUES (\'task\')')).toBe(true);
1229
});
1230
1231
it('handles multiline queries', () => {
1232
expect(isTodoRelatedSqlQuery('INSERT INTO\n todos\n (title) VALUES (\'task\')')).toBe(true);
1233
});
1234
1235
it('returns true for DROP TABLE todos', () => {
1236
expect(isTodoRelatedSqlQuery('DROP TABLE todos')).toBe(true);
1237
});
1238
1239
it('returns true for ALTER TABLE todo_deps', () => {
1240
expect(isTodoRelatedSqlQuery('ALTER TABLE todo_deps ADD COLUMN priority INTEGER')).toBe(true);
1241
});
1242
});
1243
1244
describe('updateTodoListFromSqlItems', () => {
1245
it('invokes the manage_todo_list tool with mapped items', async () => {
1246
const toolsService = new FakeToolsService();
1247
1248
await updateTodoListFromSqlItems(
1249
[
1250
{ id: '1', title: 'First task', description: 'desc1', status: 'pending' },
1251
{ id: '2', title: 'Second task', description: 'desc2', status: 'in_progress' },
1252
{ id: '3', title: 'Third task', description: '', status: 'done' },
1253
{ id: '4', title: 'Fourth task', description: 'blocked desc', status: 'blocked' },
1254
],
1255
toolsService,
1256
undefined as never,
1257
CancellationToken.None,
1258
);
1259
1260
expect(toolsService.invokeToolCalls).toHaveLength(1);
1261
expect(toolsService.invokeToolCalls[0].name).toBe('manage_todo_list');
1262
expect(toolsService.invokeToolCalls[0].input).toEqual({
1263
operation: 'write',
1264
todoList: [
1265
{ id: 0, title: 'First task', description: 'desc1', status: 'not-started' },
1266
{ id: 1, title: 'Second task', description: 'desc2', status: 'in-progress' },
1267
{ id: 2, title: 'Third task', description: '', status: 'completed' },
1268
{ id: 3, title: 'Fourth task', description: 'blocked desc', status: 'not-started' },
1269
],
1270
});
1271
});
1272
1273
it('maps unknown status to not-started', async () => {
1274
const toolsService = new FakeToolsService();
1275
1276
await updateTodoListFromSqlItems(
1277
[{ id: '1', title: 'task', description: '', status: 'unknown_status' as never }],
1278
toolsService,
1279
undefined as never,
1280
CancellationToken.None,
1281
);
1282
1283
const input = toolsService.invokeToolCalls[0].input as { todoList: { status: string }[] };
1284
expect(input.todoList[0].status).toBe('not-started');
1285
});
1286
1287
it('uses sequential ids starting from 0', async () => {
1288
const toolsService = new FakeToolsService();
1289
1290
await updateTodoListFromSqlItems(
1291
[
1292
{ id: '100', title: 'a', description: '', status: 'pending' },
1293
{ id: '200', title: 'b', description: '', status: 'done' },
1294
],
1295
toolsService,
1296
undefined as never,
1297
CancellationToken.None,
1298
);
1299
1300
const input = toolsService.invokeToolCalls[0].input as { todoList: { id: number }[] };
1301
expect(input.todoList[0].id).toBe(0);
1302
expect(input.todoList[1].id).toBe(1);
1303
});
1304
});
1305
});
1306
1307