Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
13405 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 type { ContentBlockParam, DocumentBlockParam, ImageBlockParam, MessageParam, TextBlockParam, ToolReferenceBlockParam, ToolResultBlockParam } from '@anthropic-ai/sdk/resources';
7
import { Raw } from '@vscode/prompt-tsx';
8
import { beforeEach, describe, expect, suite, test } from 'vitest';
9
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
10
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
11
import { ChatLocation } from '../../../chat/common/commonTypes';
12
import { AnthropicMessagesTool, CUSTOM_TOOL_SEARCH_NAME } from '../../../networking/common/anthropic';
13
import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking';
14
import { IToolDeferralService } from '../../../networking/common/toolDeferralService';
15
import { createPlatformServices } from '../../../test/node/services';
16
import { addLastTwoMessagesCacheControl, addToolsAndSystemCacheControl, buildToolInputSchema, createMessagesRequestBody, rawMessagesToMessagesAPI } from '../../node/messagesApi';
17
18
function assertContentArray(content: MessageParam['content']): ContentBlockParam[] {
19
expect(Array.isArray(content)).toBe(true);
20
return content as ContentBlockParam[];
21
}
22
23
function findBlock<T extends ContentBlockParam>(blocks: ContentBlockParam[], type: T['type']): T | undefined {
24
return blocks.find(b => b.type === type) as T | undefined;
25
}
26
27
function findToolResult(messages: MessageParam[]): ToolResultBlockParam | undefined {
28
for (const msg of messages.filter(m => m.role === 'user')) {
29
const content = msg.content;
30
if (Array.isArray(content)) {
31
const result = content.find((c): c is ToolResultBlockParam => c.type === 'tool_result');
32
if (result) {
33
return result;
34
}
35
}
36
}
37
return undefined;
38
}
39
40
suite('rawMessagesToMessagesAPI', function () {
41
42
test('places cache_control on tool_result block, not inside content', function () {
43
const messages: Raw.ChatMessage[] = [
44
{
45
role: Raw.ChatRole.User,
46
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Read my file' }],
47
},
48
{
49
role: Raw.ChatRole.Assistant,
50
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'I will read the file.' }],
51
toolCalls: [{
52
id: 'toolu_test123',
53
type: 'function',
54
function: { name: 'read_file', arguments: '{"path":"/tmp/test.txt"}' },
55
}],
56
},
57
{
58
role: Raw.ChatRole.Tool,
59
toolCallId: 'toolu_test123',
60
content: [
61
{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello world' },
62
{ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: 'ephemeral' },
63
],
64
},
65
];
66
67
const result = rawMessagesToMessagesAPI(messages);
68
69
const toolResult = findToolResult(result.messages);
70
expect(toolResult).toBeDefined();
71
72
// cache_control should be on the tool_result block itself
73
expect(toolResult!.cache_control).toEqual({ type: 'ephemeral' });
74
75
// cache_control should NOT be on inner content blocks
76
if (Array.isArray(toolResult!.content)) {
77
for (const inner of toolResult!.content) {
78
expect(('cache_control' in inner) ? inner.cache_control : undefined).toBeUndefined();
79
}
80
}
81
});
82
83
test('tool_result without cache_control has no cache_control property', function () {
84
const messages: Raw.ChatMessage[] = [
85
{
86
role: Raw.ChatRole.Tool,
87
toolCallId: 'toolu_no_cache',
88
content: [
89
{ type: Raw.ChatCompletionContentPartKind.Text, text: 'result text' },
90
],
91
},
92
];
93
94
const result = rawMessagesToMessagesAPI(messages);
95
96
const toolResult = findToolResult(result.messages);
97
expect(toolResult).toBeDefined();
98
expect(toolResult!.cache_control).toBeUndefined();
99
});
100
101
test('converts base64 data URL image to Anthropic base64 image source', function () {
102
const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk';
103
const messages: Raw.ChatMessage[] = [
104
{
105
role: Raw.ChatRole.User,
106
content: [{
107
type: Raw.ChatCompletionContentPartKind.Image,
108
imageUrl: { url: `data:image/png;base64,${base64Data}` },
109
}],
110
},
111
];
112
113
const result = rawMessagesToMessagesAPI(messages);
114
const content = assertContentArray(result.messages[0].content);
115
const imageBlock = findBlock<ImageBlockParam>(content, 'image');
116
expect(imageBlock).toBeDefined();
117
expect(imageBlock!.source).toEqual({
118
type: 'base64',
119
media_type: 'image/png',
120
data: base64Data,
121
});
122
});
123
124
test('converts https URL image to Anthropic url image source', function () {
125
const imageUrl = 'https://example.com/image.png';
126
const messages: Raw.ChatMessage[] = [
127
{
128
role: Raw.ChatRole.User,
129
content: [{
130
type: Raw.ChatCompletionContentPartKind.Image,
131
imageUrl: { url: imageUrl },
132
}],
133
},
134
];
135
136
const result = rawMessagesToMessagesAPI(messages);
137
const content = assertContentArray(result.messages[0].content);
138
const imageBlock = findBlock<ImageBlockParam>(content, 'image');
139
expect(imageBlock).toBeDefined();
140
expect(imageBlock!.source).toEqual({
141
type: 'url',
142
url: imageUrl,
143
});
144
});
145
146
test('drops image with unsupported URL scheme', function () {
147
const messages: Raw.ChatMessage[] = [
148
{
149
role: Raw.ChatRole.User,
150
content: [
151
{ type: Raw.ChatCompletionContentPartKind.Text, text: 'look at this' },
152
{
153
type: Raw.ChatCompletionContentPartKind.Image,
154
imageUrl: { url: 'http://insecure.example.com/image.png' },
155
},
156
],
157
},
158
];
159
160
const result = rawMessagesToMessagesAPI(messages);
161
const content = assertContentArray(result.messages[0].content);
162
expect(findBlock<ImageBlockParam>(content, 'image')).toBeUndefined();
163
expect(findBlock(content, 'text')).toBeDefined();
164
});
165
166
suite('custom tool search tool_reference conversion', function () {
167
168
function makeToolSearchMessages(toolNames: string[]): Raw.ChatMessage[] {
169
return [
170
{
171
role: Raw.ChatRole.User,
172
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'find github tools' }],
173
},
174
{
175
role: Raw.ChatRole.Assistant,
176
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Searching for tools.' }],
177
toolCalls: [{
178
id: 'toolu_search1',
179
type: 'function',
180
function: { name: CUSTOM_TOOL_SEARCH_NAME, arguments: '{"query":"github"}' },
181
}],
182
},
183
{
184
role: Raw.ChatRole.Tool,
185
toolCallId: 'toolu_search1',
186
content: [
187
{ type: Raw.ChatCompletionContentPartKind.Text, text: JSON.stringify(toolNames) },
188
],
189
},
190
];
191
}
192
193
test('converts tool search results into tool_reference blocks', function () {
194
const messages = makeToolSearchMessages(['mcp__github__list_issues', 'mcp__github__create_pull_request']);
195
const validToolNames = new Set(['mcp__github__list_issues', 'mcp__github__create_pull_request']);
196
197
const result = rawMessagesToMessagesAPI(messages, validToolNames);
198
199
const toolResult = findToolResult(result.messages);
200
expect(toolResult).toBeDefined();
201
const content = toolResult!.content as ToolReferenceBlockParam[];
202
expect(content).toHaveLength(2);
203
expect(content[0]).toEqual({ type: 'tool_reference', tool_name: 'mcp__github__list_issues' });
204
expect(content[1]).toEqual({ type: 'tool_reference', tool_name: 'mcp__github__create_pull_request' });
205
});
206
207
test('filters tool_reference blocks against validToolNames', function () {
208
const messages = makeToolSearchMessages(['mcp__github__list_issues', 'mcp__github__unknown_tool', 'read_file']);
209
const validToolNames = new Set(['mcp__github__list_issues', 'read_file', 'edit_file']);
210
211
const result = rawMessagesToMessagesAPI(messages, validToolNames);
212
213
const toolResult = findToolResult(result.messages);
214
expect(toolResult).toBeDefined();
215
const content = toolResult!.content as ToolReferenceBlockParam[];
216
expect(content).toHaveLength(2);
217
expect(content.map(c => c.tool_name)).toEqual(['mcp__github__list_issues', 'read_file']);
218
});
219
220
test('filters out all tool names when none are valid', function () {
221
const messages = makeToolSearchMessages(['unknown_tool_a', 'unknown_tool_b']);
222
const validToolNames = new Set(['read_file']);
223
224
const result = rawMessagesToMessagesAPI(messages, validToolNames);
225
226
const toolResult = findToolResult(result.messages);
227
expect(toolResult).toBeDefined();
228
// No valid tool references, content should be undefined (empty filtered)
229
expect(toolResult!.content).toBeUndefined();
230
});
231
232
test('falls back to text content when validToolNames is undefined (tool search disabled)', function () {
233
const messages = makeToolSearchMessages(['any_tool', 'another_tool']);
234
235
const result = rawMessagesToMessagesAPI(messages);
236
237
const toolResult = findToolResult(result.messages);
238
expect(toolResult).toBeDefined();
239
// When validToolNames is undefined, tool_reference conversion is skipped
240
// and the original text content is preserved as a fallback
241
const content = toolResult!.content as TextBlockParam[];
242
expect(content).toHaveLength(1);
243
expect(content[0].type).toBe('text');
244
});
245
246
test('returns undefined for non-JSON tool search results', function () {
247
const messages: Raw.ChatMessage[] = [
248
{
249
role: Raw.ChatRole.Assistant,
250
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }],
251
toolCalls: [{
252
id: 'toolu_bad',
253
type: 'function',
254
function: { name: CUSTOM_TOOL_SEARCH_NAME, arguments: '{"query":"test"}' },
255
}],
256
},
257
{
258
role: Raw.ChatRole.Tool,
259
toolCallId: 'toolu_bad',
260
content: [
261
{ type: Raw.ChatCompletionContentPartKind.Text, text: 'not valid json' },
262
],
263
},
264
];
265
266
const result = rawMessagesToMessagesAPI(messages);
267
268
// Falls back to normal text content since JSON parse fails
269
const toolResult = findToolResult(result.messages);
270
expect(toolResult).toBeDefined();
271
const content = toolResult!.content as ContentBlockParam[];
272
expect(content).toHaveLength(1);
273
expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'not valid json' }));
274
});
275
276
test('does not convert tool results for non-tool-search tools', function () {
277
const messages: Raw.ChatMessage[] = [
278
{
279
role: Raw.ChatRole.Assistant,
280
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }],
281
toolCalls: [{
282
id: 'toolu_read',
283
type: 'function',
284
function: { name: 'read_file', arguments: '{"path":"/tmp/test.txt"}' },
285
}],
286
},
287
{
288
role: Raw.ChatRole.Tool,
289
toolCallId: 'toolu_read',
290
content: [
291
{ type: Raw.ChatCompletionContentPartKind.Text, text: '["mcp__github__list_issues"]' },
292
],
293
},
294
];
295
296
const result = rawMessagesToMessagesAPI(messages);
297
298
const toolResult = findToolResult(result.messages);
299
expect(toolResult).toBeDefined();
300
// Should be normal text, not tool_reference blocks
301
const content = toolResult!.content as ContentBlockParam[];
302
expect(content).toHaveLength(1);
303
expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: '["mcp__github__list_issues"]' }));
304
});
305
});
306
307
test('converts document content part to Anthropic document block', function () {
308
const base64Data = 'JVBERi0xLjQKMSAwIG9iago8PC9UeXBlIC9DYXRhbG9n';
309
const messages: Raw.ChatMessage[] = [
310
{
311
role: Raw.ChatRole.User,
312
content: [{
313
type: Raw.ChatCompletionContentPartKind.Document,
314
documentData: { data: base64Data, mediaType: 'application/pdf' },
315
}],
316
},
317
];
318
319
const result = rawMessagesToMessagesAPI(messages);
320
const content = assertContentArray(result.messages[0].content);
321
const docBlock = findBlock<DocumentBlockParam>(content, 'document');
322
expect(docBlock).toBeDefined();
323
expect(docBlock!.source).toEqual({
324
type: 'base64',
325
media_type: 'application/pdf',
326
data: base64Data,
327
});
328
});
329
330
test('document content part in tool result is preserved', function () {
331
const base64Data = 'JVBERi0xLjQK';
332
const messages: Raw.ChatMessage[] = [
333
{
334
role: Raw.ChatRole.Assistant,
335
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }],
336
toolCalls: [{
337
id: 'toolu_pdf',
338
type: 'function',
339
function: { name: 'read_file', arguments: '{"path":"/tmp/doc.pdf"}' },
340
}],
341
},
342
{
343
role: Raw.ChatRole.Tool,
344
toolCallId: 'toolu_pdf',
345
content: [
346
{ type: Raw.ChatCompletionContentPartKind.Document, documentData: { data: base64Data, mediaType: 'application/pdf' } },
347
],
348
},
349
];
350
351
const result = rawMessagesToMessagesAPI(messages);
352
const toolResult = findToolResult(result.messages);
353
expect(toolResult).toBeDefined();
354
const content = toolResult!.content as DocumentBlockParam[];
355
expect(content).toHaveLength(1);
356
expect(content[0].type).toBe('document');
357
expect(content[0].source).toEqual({
358
type: 'base64',
359
media_type: 'application/pdf',
360
data: base64Data,
361
});
362
});
363
364
test('cache_control-only tool content does not produce empty inner content', function () {
365
const messages: Raw.ChatMessage[] = [
366
{
367
role: Raw.ChatRole.Tool,
368
toolCallId: 'toolu_cache_only',
369
content: [
370
{ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: 'ephemeral' },
371
],
372
},
373
];
374
375
const result = rawMessagesToMessagesAPI(messages);
376
377
const toolResult = findToolResult(result.messages);
378
expect(toolResult).toBeDefined();
379
// Orphaned cache breakpoint with no content to attach to is silently dropped
380
expect(toolResult!.cache_control).toBeUndefined();
381
expect(toolResult!.content).toBeUndefined();
382
});
383
384
test('cache breakpoint before content defers cache_control to next block', function () {
385
const messages: Raw.ChatMessage[] = [
386
{
387
role: Raw.ChatRole.User,
388
content: [
389
{ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: 'ephemeral' },
390
{ type: Raw.ChatCompletionContentPartKind.Text, text: 'hello world' },
391
],
392
},
393
];
394
395
const result = rawMessagesToMessagesAPI(messages);
396
397
expect(result.messages).toHaveLength(1);
398
const content = assertContentArray(result.messages[0].content);
399
expect(content).toHaveLength(1);
400
expect(content[0]).toEqual({
401
type: 'text',
402
text: 'hello world',
403
cache_control: { type: 'ephemeral' },
404
});
405
});
406
});
407
408
suite('addToolsAndSystemCacheControl', function () {
409
410
function makeTool(name: string, deferred = false): AnthropicMessagesTool {
411
return {
412
name,
413
description: `${name} tool`,
414
input_schema: { type: 'object', properties: {}, required: [] },
415
...(deferred ? { defer_loading: true } : {}),
416
};
417
}
418
419
function makeSystemBlock(text: string, cached = false): TextBlockParam {
420
return {
421
type: 'text',
422
text,
423
...(cached ? { cache_control: { type: 'ephemeral' as const } } : {}),
424
};
425
}
426
427
function makeMessages(...msgs: MessageParam[]): MessageParam[] {
428
return msgs;
429
}
430
431
function countCacheControl(tools: AnthropicMessagesTool[], system: TextBlockParam[] | undefined, messages: MessageParam[]): number {
432
let count = 0;
433
for (const tool of tools) {
434
if (tool.cache_control) {
435
count++;
436
}
437
}
438
if (system) {
439
for (const block of system) {
440
if (block.cache_control) {
441
count++;
442
}
443
}
444
}
445
for (const msg of messages) {
446
if (Array.isArray(msg.content)) {
447
for (const block of msg.content) {
448
if (typeof block === 'object' && 'cache_control' in block && block.cache_control) {
449
count++;
450
}
451
}
452
}
453
}
454
return count;
455
}
456
457
test('adds cache_control to last non-deferred tool and last system block', function () {
458
const tools = [makeTool('read_file'), makeTool('edit_file')];
459
const system: TextBlockParam[] = [makeSystemBlock('You are a helpful assistant.')];
460
const messagesResult = { messages: makeMessages(), system };
461
462
addToolsAndSystemCacheControl(tools, messagesResult);
463
464
expect(tools[0].cache_control).toBeUndefined();
465
expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });
466
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
467
});
468
469
test('skips deferred tools and marks last non-deferred tool', function () {
470
const tools = [makeTool('read_file'), makeTool('edit_file'), makeTool('deferred_a', true), makeTool('deferred_b', true)];
471
const system: TextBlockParam[] = [makeSystemBlock('System prompt')];
472
const messagesResult = { messages: makeMessages(), system };
473
474
addToolsAndSystemCacheControl(tools, messagesResult);
475
476
expect(tools[0].cache_control).toBeUndefined();
477
expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });
478
expect(tools[2].cache_control).toBeUndefined();
479
expect(tools[3].cache_control).toBeUndefined();
480
});
481
482
test('does nothing when all tools are deferred and system already has cache_control', function () {
483
const tools = [makeTool('deferred_a', true)];
484
const system: TextBlockParam[] = [makeSystemBlock('System prompt', true)];
485
const messagesResult = { messages: makeMessages(), system };
486
487
addToolsAndSystemCacheControl(tools, messagesResult);
488
489
expect(tools[0].cache_control).toBeUndefined();
490
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
491
});
492
493
test('does nothing when no tools and no system', function () {
494
const tools: AnthropicMessagesTool[] = [];
495
const messagesResult = { messages: makeMessages() };
496
497
addToolsAndSystemCacheControl(tools, messagesResult);
498
499
expect(tools).toHaveLength(0);
500
});
501
502
test('uses spare slot for tool when messages leave one slot available', function () {
503
const tools = [makeTool('read_file')];
504
const system: TextBlockParam[] = [makeSystemBlock('System prompt')];
505
const msg1Content: ContentBlockParam[] = [
506
{ type: 'text', text: 'msg1', cache_control: { type: 'ephemeral' } },
507
];
508
const msg2Content: ContentBlockParam[] = [
509
{ type: 'text', text: 'msg2', cache_control: { type: 'ephemeral' } },
510
];
511
const msg3Content: ContentBlockParam[] = [
512
{ type: 'text', text: 'msg3', cache_control: { type: 'ephemeral' } },
513
];
514
const messages = makeMessages(
515
{ role: 'user', content: msg1Content },
516
{ role: 'assistant', content: msg2Content },
517
{ role: 'user', content: msg3Content },
518
);
519
const messagesResult = { messages, system };
520
521
// 3 existing in messages, 1 spare slot → tool gets it, system does not
522
addToolsAndSystemCacheControl(tools, messagesResult);
523
524
expect(countCacheControl(tools, system, messages)).toBeLessThanOrEqual(4);
525
// Tool gets the spare slot
526
expect(tools[0].cache_control).toEqual({ type: 'ephemeral' });
527
// System does not — no spare slot left
528
expect(system[0].cache_control).toBeUndefined();
529
// Message breakpoints are preserved (no eviction)
530
expect(msg1Content[0]).toHaveProperty('cache_control');
531
expect(msg2Content[0]).toHaveProperty('cache_control');
532
expect(msg3Content[0]).toHaveProperty('cache_control');
533
});
534
535
test('skips adding breakpoints when all slots are occupied', function () {
536
// All 4 breakpoints on system blocks — no spare slots
537
const tools = [makeTool('read_file')];
538
const system: TextBlockParam[] = [
539
makeSystemBlock('block1', true),
540
makeSystemBlock('block2', true),
541
makeSystemBlock('block3', true),
542
makeSystemBlock('block4', true),
543
];
544
const messagesResult = { messages: makeMessages(), system };
545
546
addToolsAndSystemCacheControl(tools, messagesResult);
547
548
expect(tools[0].cache_control).toBeUndefined();
549
expect(countCacheControl(tools, system, messagesResult.messages)).toBeLessThanOrEqual(4);
550
});
551
552
test('skips adding breakpoints when all slots are occupied by messages', function () {
553
const tools = [makeTool('read_file')];
554
const system: TextBlockParam[] = [makeSystemBlock('System prompt')];
555
const messages = makeMessages(
556
{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
557
{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
558
{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
559
{ role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
560
);
561
const messagesResult = { messages, system };
562
563
addToolsAndSystemCacheControl(tools, messagesResult);
564
565
// All 4 slots occupied by messages — tool and system should not get cache_control
566
expect(tools[0].cache_control).toBeUndefined();
567
expect(system[0].cache_control).toBeUndefined();
568
expect(countCacheControl(tools, system, messages)).toBe(4);
569
});
570
571
test('prioritizes tool breakpoint over system when only one spare slot', function () {
572
const tools = [makeTool('read_file')];
573
const system: TextBlockParam[] = [makeSystemBlock('System prompt')];
574
const messages = makeMessages(
575
{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
576
{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
577
{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
578
);
579
const messagesResult = { messages, system };
580
581
// 3 existing message breakpoints, 1 spare slot → tool gets it
582
addToolsAndSystemCacheControl(tools, messagesResult);
583
584
expect(countCacheControl(tools, system, messages)).toBeLessThanOrEqual(4);
585
expect(tools[0].cache_control).toEqual({ type: 'ephemeral' });
586
expect(system[0].cache_control).toBeUndefined();
587
});
588
589
test('handles only tools, no system blocks', function () {
590
const tools = [makeTool('read_file'), makeTool('edit_file')];
591
const messagesResult = { messages: makeMessages() };
592
593
addToolsAndSystemCacheControl(tools, messagesResult);
594
595
expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });
596
expect(tools[0].cache_control).toBeUndefined();
597
});
598
599
test('handles only system, no tools', function () {
600
const tools: AnthropicMessagesTool[] = [];
601
const system: TextBlockParam[] = [makeSystemBlock('System prompt')];
602
const messagesResult = { messages: makeMessages(), system };
603
604
addToolsAndSystemCacheControl(tools, messagesResult);
605
606
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
607
});
608
});
609
610
suite('buildToolInputSchema', function () {
611
612
test('returns default schema when input is undefined', function () {
613
const result = buildToolInputSchema(undefined);
614
expect(result).toEqual({ type: 'object', properties: {} });
615
});
616
617
test('strips $schema from the input', function () {
618
const result = buildToolInputSchema({
619
$schema: 'https://json-schema.org/draft/2020-12/schema',
620
type: 'object',
621
properties: { query: { type: 'string' } },
622
required: ['query'],
623
});
624
expect(result).toEqual({
625
type: 'object',
626
properties: { query: { type: 'string' } },
627
required: ['query'],
628
});
629
expect(result).not.toHaveProperty('$schema');
630
});
631
632
test('preserves $defs and additionalProperties', function () {
633
const defs = { Foo: { type: 'object', properties: { x: { type: 'number' } } } };
634
const result = buildToolInputSchema({
635
type: 'object',
636
properties: { foo: { $ref: '#/$defs/Foo' } },
637
$defs: defs,
638
additionalProperties: false,
639
});
640
expect(result.$defs).toEqual(defs);
641
expect(result.additionalProperties).toBe(false);
642
});
643
644
test('defaults properties to empty object when not provided', function () {
645
const result = buildToolInputSchema({ type: 'object' });
646
expect(result.properties).toEqual({});
647
});
648
649
test('overrides default properties when provided in schema', function () {
650
const props = { name: { type: 'string' } };
651
const result = buildToolInputSchema({ type: 'object', properties: props });
652
expect(result.properties).toEqual(props);
653
});
654
655
test('passes through a plain schema without $schema unchanged', function () {
656
const schema = {
657
type: 'object',
658
properties: { id: { type: 'number' } },
659
required: ['id'],
660
};
661
const result = buildToolInputSchema(schema);
662
expect(result).toEqual(schema);
663
});
664
});
665
666
suite('addLastTwoMessagesCacheControl', function () {
667
668
function makeMessages(...msgs: MessageParam[]): MessageParam[] {
669
return msgs;
670
}
671
672
function makeTool(name: string, deferred = false): AnthropicMessagesTool {
673
return {
674
name,
675
description: `${name} tool`,
676
input_schema: { type: 'object', properties: {}, required: [] },
677
...(deferred ? { defer_loading: true } : {}),
678
};
679
}
680
681
function getCacheControl(block: ContentBlockParam): { type: string } | undefined {
682
return 'cache_control' in block ? (block as { cache_control?: { type: string } }).cache_control : undefined;
683
}
684
685
function countAllCacheControl(messages: MessageParam[], system?: TextBlockParam[]): number {
686
let count = 0;
687
if (system) {
688
for (const block of system) {
689
if (block.cache_control) {
690
count++;
691
}
692
}
693
}
694
for (const msg of messages) {
695
if (Array.isArray(msg.content)) {
696
for (const block of msg.content) {
697
if (typeof block === 'object' && 'cache_control' in block && block.cache_control) {
698
count++;
699
}
700
}
701
}
702
}
703
return count;
704
}
705
706
test('marks last two messages in a normal agentic loop', function () {
707
const messages = makeMessages(
708
{ role: 'user', content: [{ type: 'text', text: 'edit my file' }] as ContentBlockParam[] },
709
{ role: 'assistant', content: [{ type: 'text', text: 'calling tool' }, { type: 'tool_use', id: 'toolu_1', name: 'read_file', input: {} }] as ContentBlockParam[] },
710
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'file contents' }] }] as ContentBlockParam[] },
711
);
712
const messagesResult = { messages };
713
714
addLastTwoMessagesCacheControl(messagesResult);
715
716
const assistantContent = messages[1].content as ContentBlockParam[];
717
expect(getCacheControl(assistantContent[assistantContent.length - 1])).toEqual({ type: 'ephemeral' });
718
719
const toolResult = (messages[2].content as ContentBlockParam[])[0] as ToolResultBlockParam;
720
expect(toolResult.cache_control).toEqual({ type: 'ephemeral' });
721
722
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toBeUndefined();
723
expect(countAllCacheControl(messages)).toBe(2);
724
});
725
726
test('marks last two messages in plain chat', function () {
727
const messages = makeMessages(
728
{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
729
{ role: 'assistant', content: [{ type: 'text', text: 'hi there' }] as ContentBlockParam[] },
730
);
731
const messagesResult = { messages };
732
733
addLastTwoMessagesCacheControl(messagesResult);
734
735
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
736
expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
737
expect(countAllCacheControl(messages)).toBe(2);
738
});
739
740
test('handles single message', function () {
741
const messages = makeMessages(
742
{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
743
);
744
const messagesResult = { messages };
745
746
addLastTwoMessagesCacheControl(messagesResult);
747
748
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
749
expect(countAllCacheControl(messages)).toBe(1);
750
});
751
752
test('handles empty messages array', function () {
753
const messagesResult = { messages: [] as MessageParam[] };
754
755
addLastTwoMessagesCacheControl(messagesResult);
756
757
expect(messagesResult.messages).toHaveLength(0);
758
});
759
760
test('skips thinking and redacted_thinking blocks', function () {
761
const messages = makeMessages(
762
{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
763
{
764
role: 'assistant', content: [
765
{ type: 'thinking', thinking: 'hmm', signature: 'sig' },
766
{ type: 'text', text: 'response' },
767
] as ContentBlockParam[]
768
},
769
);
770
const messagesResult = { messages };
771
772
addLastTwoMessagesCacheControl(messagesResult);
773
774
const assistantContent = messages[1].content as ContentBlockParam[];
775
expect(getCacheControl(assistantContent[0])).toBeUndefined();
776
expect(getCacheControl(assistantContent[1])).toEqual({ type: 'ephemeral' });
777
expect(countAllCacheControl(messages)).toBe(2);
778
});
779
780
test('respects max breakpoint count when some already exist', function () {
781
const messages = makeMessages(
782
{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
783
{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
784
{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
785
{ role: 'assistant', content: [{ type: 'text', text: 'd' }] as ContentBlockParam[] },
786
{ role: 'user', content: [{ type: 'text', text: 'e' }] as ContentBlockParam[] },
787
);
788
const messagesResult = { messages };
789
790
addLastTwoMessagesCacheControl(messagesResult);
791
792
// 3 existing + 1 new = 4 total
793
expect(countAllCacheControl(messages)).toBe(4);
794
expect(getCacheControl((messages[4].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
795
// Second-to-last should NOT get one — would exceed 4
796
expect(getCacheControl((messages[3].content as ContentBlockParam[])[0])).toBeUndefined();
797
});
798
799
test('does nothing when all 4 slots are occupied', function () {
800
const messages = makeMessages(
801
{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
802
{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
803
{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
804
{ role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
805
{ role: 'user', content: [{ type: 'text', text: 'e' }] as ContentBlockParam[] },
806
);
807
const messagesResult = { messages };
808
809
addLastTwoMessagesCacheControl(messagesResult);
810
811
expect(getCacheControl((messages[4].content as ContentBlockParam[])[0])).toBeUndefined();
812
expect(countAllCacheControl(messages)).toBe(4);
813
});
814
815
test('treats trailing message with existing cache_control as already marked', function () {
816
// Regression: prior code would walk past a pre-marked tail message and
817
// add two new markers to earlier messages, ending up with 3 distinct
818
// marked messages instead of 2.
819
const messages = makeMessages(
820
{ role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },
821
{ role: 'assistant', content: [{ type: 'text', text: 'b' }] as ContentBlockParam[] },
822
{ role: 'user', content: [{ type: 'text', text: 'c' }] as ContentBlockParam[] },
823
{ role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },
824
);
825
const messagesResult = { messages };
826
827
const added = addLastTwoMessagesCacheControl(messagesResult);
828
829
expect(added).toBe(1);
830
expect(getCacheControl((messages[3].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
831
expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
832
expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toBeUndefined();
833
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toBeUndefined();
834
expect(countAllCacheControl(messages)).toBe(2);
835
});
836
837
test('does not add a second marker to a message that already has one on a non-last block', function () {
838
const messages = makeMessages(
839
{ role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },
840
{
841
role: 'assistant', content: [
842
{ type: 'text', text: 'first', cache_control: { type: 'ephemeral' } },
843
{ type: 'text', text: 'second' },
844
] as ContentBlockParam[]
845
},
846
);
847
const messagesResult = { messages };
848
849
const added = addLastTwoMessagesCacheControl(messagesResult);
850
851
// Last message already counts as marked; only the prior message gets a new marker.
852
expect(added).toBe(1);
853
const assistantContent = messages[1].content as ContentBlockParam[];
854
expect(getCacheControl(assistantContent[0])).toEqual({ type: 'ephemeral' });
855
expect(getCacheControl(assistantContent[1])).toBeUndefined();
856
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
857
expect(countAllCacheControl(messages)).toBe(2);
858
});
859
860
test('marks assistant-with-tool-calls as fork point', function () {
861
const messages = makeMessages(
862
{ role: 'user', content: [{ type: 'text', text: 'do stuff' }] as ContentBlockParam[] },
863
{
864
role: 'assistant', content: [
865
{ type: 'text', text: 'I will call tools' },
866
{ type: 'tool_use', id: 'toolu_a', name: 'tool_a', input: {} },
867
{ type: 'tool_use', id: 'toolu_b', name: 'tool_b', input: {} },
868
] as ContentBlockParam[]
869
},
870
{
871
role: 'user', content: [
872
{ type: 'tool_result', tool_use_id: 'toolu_a', content: [{ type: 'text', text: 'result a' }] },
873
{ type: 'tool_result', tool_use_id: 'toolu_b', content: [{ type: 'text', text: 'result b' }] },
874
] as ContentBlockParam[]
875
},
876
);
877
const messagesResult = { messages };
878
879
addLastTwoMessagesCacheControl(messagesResult);
880
881
const assistantContent = messages[1].content as ContentBlockParam[];
882
expect(getCacheControl(assistantContent[2])).toEqual({ type: 'ephemeral' });
883
884
const userContent = messages[2].content as ContentBlockParam[];
885
expect(getCacheControl(userContent[1])).toEqual({ type: 'ephemeral' });
886
887
expect(countAllCacheControl(messages)).toBe(2);
888
});
889
890
test('counts system block breakpoints toward the limit', function () {
891
const system: TextBlockParam[] = [
892
{ type: 'text', text: 'system', cache_control: { type: 'ephemeral' } },
893
];
894
const messages = makeMessages(
895
{ role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },
896
{ role: 'assistant', content: [{ type: 'text', text: 'b' }] as ContentBlockParam[] },
897
{ role: 'user', content: [{ type: 'text', text: 'c' }] as ContentBlockParam[] },
898
);
899
const messagesResult = { messages, system };
900
901
addLastTwoMessagesCacheControl(messagesResult);
902
903
// 1 system + 2 message breakpoints = 3 total
904
expect(countAllCacheControl(messages, system)).toBe(3);
905
expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
906
expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
907
});
908
909
test('skips tail message with empty content and marks two prior', function () {
910
const messages = makeMessages(
911
{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
912
{ role: 'assistant', content: [{ type: 'text', text: 'response' }] as ContentBlockParam[] },
913
{ role: 'user', content: [] as ContentBlockParam[] },
914
);
915
const messagesResult = { messages };
916
917
addLastTwoMessagesCacheControl(messagesResult);
918
919
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
920
expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
921
expect(countAllCacheControl(messages)).toBe(2);
922
});
923
924
test('skips thinking-only tail message and marks two prior', function () {
925
const messages = makeMessages(
926
{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
927
{ role: 'assistant', content: [{ type: 'text', text: 'first response' }] as ContentBlockParam[] },
928
{
929
role: 'assistant', content: [
930
{ type: 'thinking', thinking: 'deep thought', signature: 'sig' },
931
{ type: 'redacted_thinking', data: 'redacted' },
932
] as ContentBlockParam[]
933
},
934
);
935
const messagesResult = { messages };
936
937
addLastTwoMessagesCacheControl(messagesResult);
938
939
// Thinking-only message has no cacheable blocks — skip it
940
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
941
expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
942
expect(countAllCacheControl(messages)).toBe(2);
943
});
944
945
test('skips empty middle message and still finds two cacheable', function () {
946
const messages = makeMessages(
947
{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },
948
{ role: 'assistant', content: [] as ContentBlockParam[] },
949
{ role: 'user', content: [{ type: 'text', text: 'follow up' }] as ContentBlockParam[] },
950
);
951
const messagesResult = { messages };
952
953
addLastTwoMessagesCacheControl(messagesResult);
954
955
// Last message + first message (middle is empty, skipped)
956
expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
957
expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });
958
expect(countAllCacheControl(messages)).toBe(2);
959
});
960
961
test('round-trip with addToolsAndSystemCacheControl produces exactly 4 markers', function () {
962
const tools = [makeTool('read_file'), makeTool('edit_file')];
963
const system: TextBlockParam[] = [{ type: 'text', text: 'You are a helpful assistant.' }];
964
const messages = makeMessages(
965
{ role: 'user', content: [{ type: 'text', text: 'edit my file' }] as ContentBlockParam[] },
966
{ role: 'assistant', content: [{ type: 'text', text: 'calling tool' }, { type: 'tool_use', id: 'toolu_1', name: 'read_file', input: {} }] as ContentBlockParam[] },
967
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'file contents' }] }] as ContentBlockParam[] },
968
);
969
const messagesResult = { messages, system };
970
971
// Call both in the same order as createMessagesRequestBody
972
addLastTwoMessagesCacheControl(messagesResult);
973
addToolsAndSystemCacheControl(tools, messagesResult);
974
975
// 2 message breakpoints + 1 tool + 1 system = 4
976
let totalCount = countAllCacheControl(messages, system);
977
for (const tool of tools) {
978
if (tool.cache_control) {
979
totalCount++;
980
}
981
}
982
expect(totalCount).toBe(4);
983
984
// Verify positions
985
const assistantContent = messages[1].content as ContentBlockParam[];
986
expect(getCacheControl(assistantContent[assistantContent.length - 1])).toEqual({ type: 'ephemeral' });
987
expect(((messages[2].content as ContentBlockParam[])[0] as ToolResultBlockParam).cache_control).toEqual({ type: 'ephemeral' });
988
expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });
989
expect(system[0].cache_control).toEqual({ type: 'ephemeral' });
990
});
991
});
992
993
describe('createMessagesRequestBody reasoning effort', () => {
994
let disposables: DisposableStore;
995
let instantiationService: IInstantiationService;
996
997
function createMockEndpoint(overrides: Partial<IChatEndpoint> = {}): IChatEndpoint {
998
return {
999
model: 'claude-sonnet-4.5',
1000
family: 'claude-sonnet-4.5',
1001
modelProvider: 'Anthropic',
1002
maxOutputTokens: 8192,
1003
modelMaxPromptTokens: 200000,
1004
supportsToolCalls: true,
1005
supportsVision: true,
1006
supportsPrediction: false,
1007
showInModelPicker: true,
1008
isFallback: false,
1009
name: 'test',
1010
version: '1.0',
1011
policy: 'enabled',
1012
urlOrRequestMetadata: 'https://test.com',
1013
tokenizer: 0,
1014
isDefault: false,
1015
processResponseFromChatEndpoint: () => { throw new Error('not implemented'); },
1016
acceptChatPolicy: () => { throw new Error('not implemented'); },
1017
makeChatRequest2: () => { throw new Error('not implemented'); },
1018
createRequestBody: () => { throw new Error('not implemented'); },
1019
cloneWithTokenOverride: () => { throw new Error('not implemented'); },
1020
interceptBody: () => { },
1021
getExtraHeaders: () => ({}),
1022
...overrides,
1023
} as IChatEndpoint;
1024
}
1025
1026
function createMinimalOptions(overrides: Partial<ICreateEndpointBodyOptions> = {}): ICreateEndpointBodyOptions {
1027
return {
1028
debugName: 'test',
1029
requestId: 'test-request-id',
1030
finishedCb: undefined,
1031
messages: [{
1032
role: Raw.ChatRole.User,
1033
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }],
1034
}],
1035
postOptions: { max_tokens: 8192 },
1036
location: ChatLocation.Panel,
1037
...overrides,
1038
};
1039
}
1040
1041
beforeEach(() => {
1042
disposables = new DisposableStore();
1043
const services = disposables.add(createPlatformServices(disposables));
1044
services.define(IToolDeferralService, {
1045
_serviceBrand: undefined,
1046
isNonDeferredTool: () => true,
1047
});
1048
const accessor = services.createTestingAccessor();
1049
instantiationService = accessor.get(IInstantiationService);
1050
});
1051
1052
test('includes effort in output_config when model supports reasoning effort and thinking is adaptive', () => {
1053
const endpoint = createMockEndpoint({
1054
supportsAdaptiveThinking: true,
1055
supportsReasoningEffort: ['low', 'medium', 'high'],
1056
});
1057
const options = createMinimalOptions({
1058
modelCapabilities: { enableThinking: true, reasoningEffort: 'high' },
1059
});
1060
1061
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1062
1063
expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });
1064
expect(body.output_config).toEqual({ effort: 'high' });
1065
});
1066
1067
test('omits effort when model does not declare supportsReasoningEffort', () => {
1068
const endpoint = createMockEndpoint({
1069
supportsAdaptiveThinking: true,
1070
// supportsReasoningEffort is undefined
1071
});
1072
const options = createMinimalOptions({
1073
modelCapabilities: { enableThinking: true, reasoningEffort: 'high' },
1074
});
1075
1076
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1077
1078
expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });
1079
expect(body.output_config).toBeUndefined();
1080
});
1081
1082
test('omits effort when supportsReasoningEffort is an empty array', () => {
1083
const endpoint = createMockEndpoint({
1084
supportsAdaptiveThinking: true,
1085
supportsReasoningEffort: [],
1086
});
1087
const options = createMinimalOptions({
1088
modelCapabilities: { enableThinking: true, reasoningEffort: 'medium' },
1089
});
1090
1091
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1092
1093
expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });
1094
expect(body.output_config).toBeUndefined();
1095
});
1096
1097
test('omits effort when thinking is not enabled', () => {
1098
const endpoint = createMockEndpoint({
1099
supportsAdaptiveThinking: true,
1100
supportsReasoningEffort: ['low', 'medium', 'high'],
1101
});
1102
const options = createMinimalOptions({
1103
modelCapabilities: { enableThinking: false, reasoningEffort: 'high' },
1104
});
1105
1106
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1107
1108
expect(body.thinking).toBeUndefined();
1109
expect(body.output_config).toBeUndefined();
1110
});
1111
1112
test('omits effort when reasoningEffort is an invalid value', () => {
1113
const endpoint = createMockEndpoint({
1114
supportsAdaptiveThinking: true,
1115
supportsReasoningEffort: ['low', 'medium', 'high'],
1116
});
1117
const options = createMinimalOptions({
1118
modelCapabilities: { enableThinking: true, reasoningEffort: 'xhigh' as any },
1119
});
1120
1121
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1122
1123
expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });
1124
expect(body.output_config).toBeUndefined();
1125
});
1126
1127
test('uses budget_tokens thinking when model has maxThinkingBudget but not adaptive', () => {
1128
const endpoint = createMockEndpoint({
1129
supportsAdaptiveThinking: false,
1130
maxThinkingBudget: 32000,
1131
minThinkingBudget: 1024,
1132
supportsReasoningEffort: ['low', 'medium', 'high'],
1133
});
1134
const options = createMinimalOptions({
1135
modelCapabilities: { enableThinking: true, reasoningEffort: 'low' },
1136
});
1137
1138
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1139
1140
expect(body.thinking).toEqual({ type: 'enabled', budget_tokens: 8191 });
1141
expect(body.output_config).toEqual({ effort: 'low' });
1142
});
1143
});
1144
1145
describe('createMessagesRequestBody tool search deferral', () => {
1146
let disposables: DisposableStore;
1147
let instantiationService: IInstantiationService;
1148
1149
function createMockEndpoint(supportsToolSearch: boolean): IChatEndpoint {
1150
return {
1151
model: 'claude-sonnet-4.6',
1152
family: 'claude-sonnet-4.6',
1153
modelProvider: 'Anthropic',
1154
maxOutputTokens: 8192,
1155
modelMaxPromptTokens: 200000,
1156
supportsToolCalls: true,
1157
supportsVision: true,
1158
supportsPrediction: false,
1159
supportsToolSearch,
1160
showInModelPicker: true,
1161
isFallback: false,
1162
name: 'test',
1163
version: '1.0',
1164
policy: 'enabled',
1165
urlOrRequestMetadata: 'https://test.com',
1166
tokenizer: 0,
1167
isDefault: false,
1168
processResponseFromChatEndpoint: () => { throw new Error('not implemented'); },
1169
acceptChatPolicy: () => { throw new Error('not implemented'); },
1170
makeChatRequest2: () => { throw new Error('not implemented'); },
1171
createRequestBody: () => { throw new Error('not implemented'); },
1172
cloneWithTokenOverride: () => { throw new Error('not implemented'); },
1173
interceptBody: () => { },
1174
getExtraHeaders: () => ({}),
1175
} as unknown as IChatEndpoint;
1176
}
1177
1178
function makeTool(name: string) {
1179
return { type: 'function' as const, function: { name, description: `${name} tool`, parameters: { type: 'object', properties: {} } } };
1180
}
1181
1182
function createOptions(tools: ReturnType<typeof makeTool>[]): ICreateEndpointBodyOptions {
1183
return {
1184
debugName: 'test',
1185
requestId: 'test-request-id',
1186
finishedCb: undefined,
1187
messages: [{
1188
role: Raw.ChatRole.User,
1189
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }],
1190
}],
1191
postOptions: { max_tokens: 8192 },
1192
location: ChatLocation.Agent,
1193
modelCapabilities: { enableToolSearch: true },
1194
requestOptions: { tools },
1195
} as ICreateEndpointBodyOptions;
1196
}
1197
1198
beforeEach(() => {
1199
disposables = new DisposableStore();
1200
const services = disposables.add(createPlatformServices(disposables));
1201
// Non-deferred allowlist matches production: core tools + tool_search itself.
1202
const nonDeferred = new Set(['read_file', 'grep_search', CUSTOM_TOOL_SEARCH_NAME]);
1203
services.define(IToolDeferralService, {
1204
_serviceBrand: undefined,
1205
isNonDeferredTool: (name: string) => nonDeferred.has(name),
1206
});
1207
const accessor = services.createTestingAccessor();
1208
instantiationService = accessor.get(IInstantiationService);
1209
});
1210
1211
test('does not set defer_loading when tool_search is not in the request tool list', () => {
1212
// Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent
1213
// with `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate,
1214
// every MCP tool gets defer_loading=true and Anthropic rejects the request with
1215
// "At least one tool must have defer_loading=false."
1216
const endpoint = createMockEndpoint(true);
1217
const options = createOptions([makeTool('some_mcp_tool'), makeTool('another_mcp_tool')]);
1218
1219
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1220
1221
const tools = body.tools as AnthropicMessagesTool[];
1222
expect(tools.every(t => !t.defer_loading)).toBe(true);
1223
expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined();
1224
expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined();
1225
});
1226
1227
test('defers MCP tools when tool_search is in the request tool list', () => {
1228
const endpoint = createMockEndpoint(true);
1229
const options = createOptions([
1230
makeTool('read_file'),
1231
makeTool('some_mcp_tool'),
1232
makeTool(CUSTOM_TOOL_SEARCH_NAME),
1233
]);
1234
1235
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1236
1237
const tools = body.tools as AnthropicMessagesTool[];
1238
expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined();
1239
expect(tools.find(t => t.name === CUSTOM_TOOL_SEARCH_NAME)?.defer_loading).toBeUndefined();
1240
expect(tools.find(t => t.name === 'some_mcp_tool')?.defer_loading).toBe(true);
1241
});
1242
1243
test('does not defer when endpoint does not support tool search', () => {
1244
const endpoint = createMockEndpoint(false);
1245
const options = createOptions([makeTool('read_file'), makeTool('some_mcp_tool'), makeTool(CUSTOM_TOOL_SEARCH_NAME)]);
1246
1247
const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);
1248
1249
const tools = body.tools as AnthropicMessagesTool[];
1250
expect(tools.every(t => !t.defer_loading)).toBe(true);
1251
});
1252
});
1253
1254