Path: blob/main/extensions/copilot/src/platform/endpoint/test/node/messagesApi.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { ContentBlockParam, DocumentBlockParam, ImageBlockParam, MessageParam, TextBlockParam, ToolReferenceBlockParam, ToolResultBlockParam } from '@anthropic-ai/sdk/resources';6import { Raw } from '@vscode/prompt-tsx';7import { beforeEach, describe, expect, suite, test } from 'vitest';8import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';9import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';10import { ChatLocation } from '../../../chat/common/commonTypes';11import { AnthropicMessagesTool, CUSTOM_TOOL_SEARCH_NAME } from '../../../networking/common/anthropic';12import { IChatEndpoint, ICreateEndpointBodyOptions } from '../../../networking/common/networking';13import { IToolDeferralService } from '../../../networking/common/toolDeferralService';14import { createPlatformServices } from '../../../test/node/services';15import { addLastTwoMessagesCacheControl, addToolsAndSystemCacheControl, buildToolInputSchema, createMessagesRequestBody, rawMessagesToMessagesAPI } from '../../node/messagesApi';1617function assertContentArray(content: MessageParam['content']): ContentBlockParam[] {18expect(Array.isArray(content)).toBe(true);19return content as ContentBlockParam[];20}2122function findBlock<T extends ContentBlockParam>(blocks: ContentBlockParam[], type: T['type']): T | undefined {23return blocks.find(b => b.type === type) as T | undefined;24}2526function findToolResult(messages: MessageParam[]): ToolResultBlockParam | undefined {27for (const msg of messages.filter(m => m.role === 'user')) {28const content = msg.content;29if (Array.isArray(content)) {30const result = content.find((c): c is ToolResultBlockParam => c.type === 'tool_result');31if (result) {32return result;33}34}35}36return undefined;37}3839suite('rawMessagesToMessagesAPI', function () {4041test('places cache_control on tool_result block, not inside content', function () {42const messages: Raw.ChatMessage[] = [43{44role: Raw.ChatRole.User,45content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Read my file' }],46},47{48role: Raw.ChatRole.Assistant,49content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'I will read the file.' }],50toolCalls: [{51id: 'toolu_test123',52type: 'function',53function: { name: 'read_file', arguments: '{"path":"/tmp/test.txt"}' },54}],55},56{57role: Raw.ChatRole.Tool,58toolCallId: 'toolu_test123',59content: [60{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello world' },61{ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: 'ephemeral' },62],63},64];6566const result = rawMessagesToMessagesAPI(messages);6768const toolResult = findToolResult(result.messages);69expect(toolResult).toBeDefined();7071// cache_control should be on the tool_result block itself72expect(toolResult!.cache_control).toEqual({ type: 'ephemeral' });7374// cache_control should NOT be on inner content blocks75if (Array.isArray(toolResult!.content)) {76for (const inner of toolResult!.content) {77expect(('cache_control' in inner) ? inner.cache_control : undefined).toBeUndefined();78}79}80});8182test('tool_result without cache_control has no cache_control property', function () {83const messages: Raw.ChatMessage[] = [84{85role: Raw.ChatRole.Tool,86toolCallId: 'toolu_no_cache',87content: [88{ type: Raw.ChatCompletionContentPartKind.Text, text: 'result text' },89],90},91];9293const result = rawMessagesToMessagesAPI(messages);9495const toolResult = findToolResult(result.messages);96expect(toolResult).toBeDefined();97expect(toolResult!.cache_control).toBeUndefined();98});99100test('converts base64 data URL image to Anthropic base64 image source', function () {101const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk';102const messages: Raw.ChatMessage[] = [103{104role: Raw.ChatRole.User,105content: [{106type: Raw.ChatCompletionContentPartKind.Image,107imageUrl: { url: `data:image/png;base64,${base64Data}` },108}],109},110];111112const result = rawMessagesToMessagesAPI(messages);113const content = assertContentArray(result.messages[0].content);114const imageBlock = findBlock<ImageBlockParam>(content, 'image');115expect(imageBlock).toBeDefined();116expect(imageBlock!.source).toEqual({117type: 'base64',118media_type: 'image/png',119data: base64Data,120});121});122123test('converts https URL image to Anthropic url image source', function () {124const imageUrl = 'https://example.com/image.png';125const messages: Raw.ChatMessage[] = [126{127role: Raw.ChatRole.User,128content: [{129type: Raw.ChatCompletionContentPartKind.Image,130imageUrl: { url: imageUrl },131}],132},133];134135const result = rawMessagesToMessagesAPI(messages);136const content = assertContentArray(result.messages[0].content);137const imageBlock = findBlock<ImageBlockParam>(content, 'image');138expect(imageBlock).toBeDefined();139expect(imageBlock!.source).toEqual({140type: 'url',141url: imageUrl,142});143});144145test('drops image with unsupported URL scheme', function () {146const messages: Raw.ChatMessage[] = [147{148role: Raw.ChatRole.User,149content: [150{ type: Raw.ChatCompletionContentPartKind.Text, text: 'look at this' },151{152type: Raw.ChatCompletionContentPartKind.Image,153imageUrl: { url: 'http://insecure.example.com/image.png' },154},155],156},157];158159const result = rawMessagesToMessagesAPI(messages);160const content = assertContentArray(result.messages[0].content);161expect(findBlock<ImageBlockParam>(content, 'image')).toBeUndefined();162expect(findBlock(content, 'text')).toBeDefined();163});164165suite('custom tool search tool_reference conversion', function () {166167function makeToolSearchMessages(toolNames: string[]): Raw.ChatMessage[] {168return [169{170role: Raw.ChatRole.User,171content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'find github tools' }],172},173{174role: Raw.ChatRole.Assistant,175content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Searching for tools.' }],176toolCalls: [{177id: 'toolu_search1',178type: 'function',179function: { name: CUSTOM_TOOL_SEARCH_NAME, arguments: '{"query":"github"}' },180}],181},182{183role: Raw.ChatRole.Tool,184toolCallId: 'toolu_search1',185content: [186{ type: Raw.ChatCompletionContentPartKind.Text, text: JSON.stringify(toolNames) },187],188},189];190}191192test('converts tool search results into tool_reference blocks', function () {193const messages = makeToolSearchMessages(['mcp__github__list_issues', 'mcp__github__create_pull_request']);194const validToolNames = new Set(['mcp__github__list_issues', 'mcp__github__create_pull_request']);195196const result = rawMessagesToMessagesAPI(messages, validToolNames);197198const toolResult = findToolResult(result.messages);199expect(toolResult).toBeDefined();200const content = toolResult!.content as ToolReferenceBlockParam[];201expect(content).toHaveLength(2);202expect(content[0]).toEqual({ type: 'tool_reference', tool_name: 'mcp__github__list_issues' });203expect(content[1]).toEqual({ type: 'tool_reference', tool_name: 'mcp__github__create_pull_request' });204});205206test('filters tool_reference blocks against validToolNames', function () {207const messages = makeToolSearchMessages(['mcp__github__list_issues', 'mcp__github__unknown_tool', 'read_file']);208const validToolNames = new Set(['mcp__github__list_issues', 'read_file', 'edit_file']);209210const result = rawMessagesToMessagesAPI(messages, validToolNames);211212const toolResult = findToolResult(result.messages);213expect(toolResult).toBeDefined();214const content = toolResult!.content as ToolReferenceBlockParam[];215expect(content).toHaveLength(2);216expect(content.map(c => c.tool_name)).toEqual(['mcp__github__list_issues', 'read_file']);217});218219test('filters out all tool names when none are valid', function () {220const messages = makeToolSearchMessages(['unknown_tool_a', 'unknown_tool_b']);221const validToolNames = new Set(['read_file']);222223const result = rawMessagesToMessagesAPI(messages, validToolNames);224225const toolResult = findToolResult(result.messages);226expect(toolResult).toBeDefined();227// No valid tool references, content should be undefined (empty filtered)228expect(toolResult!.content).toBeUndefined();229});230231test('falls back to text content when validToolNames is undefined (tool search disabled)', function () {232const messages = makeToolSearchMessages(['any_tool', 'another_tool']);233234const result = rawMessagesToMessagesAPI(messages);235236const toolResult = findToolResult(result.messages);237expect(toolResult).toBeDefined();238// When validToolNames is undefined, tool_reference conversion is skipped239// and the original text content is preserved as a fallback240const content = toolResult!.content as TextBlockParam[];241expect(content).toHaveLength(1);242expect(content[0].type).toBe('text');243});244245test('returns undefined for non-JSON tool search results', function () {246const messages: Raw.ChatMessage[] = [247{248role: Raw.ChatRole.Assistant,249content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }],250toolCalls: [{251id: 'toolu_bad',252type: 'function',253function: { name: CUSTOM_TOOL_SEARCH_NAME, arguments: '{"query":"test"}' },254}],255},256{257role: Raw.ChatRole.Tool,258toolCallId: 'toolu_bad',259content: [260{ type: Raw.ChatCompletionContentPartKind.Text, text: 'not valid json' },261],262},263];264265const result = rawMessagesToMessagesAPI(messages);266267// Falls back to normal text content since JSON parse fails268const toolResult = findToolResult(result.messages);269expect(toolResult).toBeDefined();270const content = toolResult!.content as ContentBlockParam[];271expect(content).toHaveLength(1);272expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: 'not valid json' }));273});274275test('does not convert tool results for non-tool-search tools', function () {276const messages: Raw.ChatMessage[] = [277{278role: Raw.ChatRole.Assistant,279content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }],280toolCalls: [{281id: 'toolu_read',282type: 'function',283function: { name: 'read_file', arguments: '{"path":"/tmp/test.txt"}' },284}],285},286{287role: Raw.ChatRole.Tool,288toolCallId: 'toolu_read',289content: [290{ type: Raw.ChatCompletionContentPartKind.Text, text: '["mcp__github__list_issues"]' },291],292},293];294295const result = rawMessagesToMessagesAPI(messages);296297const toolResult = findToolResult(result.messages);298expect(toolResult).toBeDefined();299// Should be normal text, not tool_reference blocks300const content = toolResult!.content as ContentBlockParam[];301expect(content).toHaveLength(1);302expect(content[0]).toEqual(expect.objectContaining({ type: 'text', text: '["mcp__github__list_issues"]' }));303});304});305306test('converts document content part to Anthropic document block', function () {307const base64Data = 'JVBERi0xLjQKMSAwIG9iago8PC9UeXBlIC9DYXRhbG9n';308const messages: Raw.ChatMessage[] = [309{310role: Raw.ChatRole.User,311content: [{312type: Raw.ChatCompletionContentPartKind.Document,313documentData: { data: base64Data, mediaType: 'application/pdf' },314}],315},316];317318const result = rawMessagesToMessagesAPI(messages);319const content = assertContentArray(result.messages[0].content);320const docBlock = findBlock<DocumentBlockParam>(content, 'document');321expect(docBlock).toBeDefined();322expect(docBlock!.source).toEqual({323type: 'base64',324media_type: 'application/pdf',325data: base64Data,326});327});328329test('document content part in tool result is preserved', function () {330const base64Data = 'JVBERi0xLjQK';331const messages: Raw.ChatMessage[] = [332{333role: Raw.ChatRole.Assistant,334content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }],335toolCalls: [{336id: 'toolu_pdf',337type: 'function',338function: { name: 'read_file', arguments: '{"path":"/tmp/doc.pdf"}' },339}],340},341{342role: Raw.ChatRole.Tool,343toolCallId: 'toolu_pdf',344content: [345{ type: Raw.ChatCompletionContentPartKind.Document, documentData: { data: base64Data, mediaType: 'application/pdf' } },346],347},348];349350const result = rawMessagesToMessagesAPI(messages);351const toolResult = findToolResult(result.messages);352expect(toolResult).toBeDefined();353const content = toolResult!.content as DocumentBlockParam[];354expect(content).toHaveLength(1);355expect(content[0].type).toBe('document');356expect(content[0].source).toEqual({357type: 'base64',358media_type: 'application/pdf',359data: base64Data,360});361});362363test('cache_control-only tool content does not produce empty inner content', function () {364const messages: Raw.ChatMessage[] = [365{366role: Raw.ChatRole.Tool,367toolCallId: 'toolu_cache_only',368content: [369{ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: 'ephemeral' },370],371},372];373374const result = rawMessagesToMessagesAPI(messages);375376const toolResult = findToolResult(result.messages);377expect(toolResult).toBeDefined();378// Orphaned cache breakpoint with no content to attach to is silently dropped379expect(toolResult!.cache_control).toBeUndefined();380expect(toolResult!.content).toBeUndefined();381});382383test('cache breakpoint before content defers cache_control to next block', function () {384const messages: Raw.ChatMessage[] = [385{386role: Raw.ChatRole.User,387content: [388{ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: 'ephemeral' },389{ type: Raw.ChatCompletionContentPartKind.Text, text: 'hello world' },390],391},392];393394const result = rawMessagesToMessagesAPI(messages);395396expect(result.messages).toHaveLength(1);397const content = assertContentArray(result.messages[0].content);398expect(content).toHaveLength(1);399expect(content[0]).toEqual({400type: 'text',401text: 'hello world',402cache_control: { type: 'ephemeral' },403});404});405});406407suite('addToolsAndSystemCacheControl', function () {408409function makeTool(name: string, deferred = false): AnthropicMessagesTool {410return {411name,412description: `${name} tool`,413input_schema: { type: 'object', properties: {}, required: [] },414...(deferred ? { defer_loading: true } : {}),415};416}417418function makeSystemBlock(text: string, cached = false): TextBlockParam {419return {420type: 'text',421text,422...(cached ? { cache_control: { type: 'ephemeral' as const } } : {}),423};424}425426function makeMessages(...msgs: MessageParam[]): MessageParam[] {427return msgs;428}429430function countCacheControl(tools: AnthropicMessagesTool[], system: TextBlockParam[] | undefined, messages: MessageParam[]): number {431let count = 0;432for (const tool of tools) {433if (tool.cache_control) {434count++;435}436}437if (system) {438for (const block of system) {439if (block.cache_control) {440count++;441}442}443}444for (const msg of messages) {445if (Array.isArray(msg.content)) {446for (const block of msg.content) {447if (typeof block === 'object' && 'cache_control' in block && block.cache_control) {448count++;449}450}451}452}453return count;454}455456test('adds cache_control to last non-deferred tool and last system block', function () {457const tools = [makeTool('read_file'), makeTool('edit_file')];458const system: TextBlockParam[] = [makeSystemBlock('You are a helpful assistant.')];459const messagesResult = { messages: makeMessages(), system };460461addToolsAndSystemCacheControl(tools, messagesResult);462463expect(tools[0].cache_control).toBeUndefined();464expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });465expect(system[0].cache_control).toEqual({ type: 'ephemeral' });466});467468test('skips deferred tools and marks last non-deferred tool', function () {469const tools = [makeTool('read_file'), makeTool('edit_file'), makeTool('deferred_a', true), makeTool('deferred_b', true)];470const system: TextBlockParam[] = [makeSystemBlock('System prompt')];471const messagesResult = { messages: makeMessages(), system };472473addToolsAndSystemCacheControl(tools, messagesResult);474475expect(tools[0].cache_control).toBeUndefined();476expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });477expect(tools[2].cache_control).toBeUndefined();478expect(tools[3].cache_control).toBeUndefined();479});480481test('does nothing when all tools are deferred and system already has cache_control', function () {482const tools = [makeTool('deferred_a', true)];483const system: TextBlockParam[] = [makeSystemBlock('System prompt', true)];484const messagesResult = { messages: makeMessages(), system };485486addToolsAndSystemCacheControl(tools, messagesResult);487488expect(tools[0].cache_control).toBeUndefined();489expect(system[0].cache_control).toEqual({ type: 'ephemeral' });490});491492test('does nothing when no tools and no system', function () {493const tools: AnthropicMessagesTool[] = [];494const messagesResult = { messages: makeMessages() };495496addToolsAndSystemCacheControl(tools, messagesResult);497498expect(tools).toHaveLength(0);499});500501test('uses spare slot for tool when messages leave one slot available', function () {502const tools = [makeTool('read_file')];503const system: TextBlockParam[] = [makeSystemBlock('System prompt')];504const msg1Content: ContentBlockParam[] = [505{ type: 'text', text: 'msg1', cache_control: { type: 'ephemeral' } },506];507const msg2Content: ContentBlockParam[] = [508{ type: 'text', text: 'msg2', cache_control: { type: 'ephemeral' } },509];510const msg3Content: ContentBlockParam[] = [511{ type: 'text', text: 'msg3', cache_control: { type: 'ephemeral' } },512];513const messages = makeMessages(514{ role: 'user', content: msg1Content },515{ role: 'assistant', content: msg2Content },516{ role: 'user', content: msg3Content },517);518const messagesResult = { messages, system };519520// 3 existing in messages, 1 spare slot → tool gets it, system does not521addToolsAndSystemCacheControl(tools, messagesResult);522523expect(countCacheControl(tools, system, messages)).toBeLessThanOrEqual(4);524// Tool gets the spare slot525expect(tools[0].cache_control).toEqual({ type: 'ephemeral' });526// System does not — no spare slot left527expect(system[0].cache_control).toBeUndefined();528// Message breakpoints are preserved (no eviction)529expect(msg1Content[0]).toHaveProperty('cache_control');530expect(msg2Content[0]).toHaveProperty('cache_control');531expect(msg3Content[0]).toHaveProperty('cache_control');532});533534test('skips adding breakpoints when all slots are occupied', function () {535// All 4 breakpoints on system blocks — no spare slots536const tools = [makeTool('read_file')];537const system: TextBlockParam[] = [538makeSystemBlock('block1', true),539makeSystemBlock('block2', true),540makeSystemBlock('block3', true),541makeSystemBlock('block4', true),542];543const messagesResult = { messages: makeMessages(), system };544545addToolsAndSystemCacheControl(tools, messagesResult);546547expect(tools[0].cache_control).toBeUndefined();548expect(countCacheControl(tools, system, messagesResult.messages)).toBeLessThanOrEqual(4);549});550551test('skips adding breakpoints when all slots are occupied by messages', function () {552const tools = [makeTool('read_file')];553const system: TextBlockParam[] = [makeSystemBlock('System prompt')];554const messages = makeMessages(555{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },556{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },557{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },558{ role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },559);560const messagesResult = { messages, system };561562addToolsAndSystemCacheControl(tools, messagesResult);563564// All 4 slots occupied by messages — tool and system should not get cache_control565expect(tools[0].cache_control).toBeUndefined();566expect(system[0].cache_control).toBeUndefined();567expect(countCacheControl(tools, system, messages)).toBe(4);568});569570test('prioritizes tool breakpoint over system when only one spare slot', function () {571const tools = [makeTool('read_file')];572const system: TextBlockParam[] = [makeSystemBlock('System prompt')];573const messages = makeMessages(574{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },575{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },576{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },577);578const messagesResult = { messages, system };579580// 3 existing message breakpoints, 1 spare slot → tool gets it581addToolsAndSystemCacheControl(tools, messagesResult);582583expect(countCacheControl(tools, system, messages)).toBeLessThanOrEqual(4);584expect(tools[0].cache_control).toEqual({ type: 'ephemeral' });585expect(system[0].cache_control).toBeUndefined();586});587588test('handles only tools, no system blocks', function () {589const tools = [makeTool('read_file'), makeTool('edit_file')];590const messagesResult = { messages: makeMessages() };591592addToolsAndSystemCacheControl(tools, messagesResult);593594expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });595expect(tools[0].cache_control).toBeUndefined();596});597598test('handles only system, no tools', function () {599const tools: AnthropicMessagesTool[] = [];600const system: TextBlockParam[] = [makeSystemBlock('System prompt')];601const messagesResult = { messages: makeMessages(), system };602603addToolsAndSystemCacheControl(tools, messagesResult);604605expect(system[0].cache_control).toEqual({ type: 'ephemeral' });606});607});608609suite('buildToolInputSchema', function () {610611test('returns default schema when input is undefined', function () {612const result = buildToolInputSchema(undefined);613expect(result).toEqual({ type: 'object', properties: {} });614});615616test('strips $schema from the input', function () {617const result = buildToolInputSchema({618$schema: 'https://json-schema.org/draft/2020-12/schema',619type: 'object',620properties: { query: { type: 'string' } },621required: ['query'],622});623expect(result).toEqual({624type: 'object',625properties: { query: { type: 'string' } },626required: ['query'],627});628expect(result).not.toHaveProperty('$schema');629});630631test('preserves $defs and additionalProperties', function () {632const defs = { Foo: { type: 'object', properties: { x: { type: 'number' } } } };633const result = buildToolInputSchema({634type: 'object',635properties: { foo: { $ref: '#/$defs/Foo' } },636$defs: defs,637additionalProperties: false,638});639expect(result.$defs).toEqual(defs);640expect(result.additionalProperties).toBe(false);641});642643test('defaults properties to empty object when not provided', function () {644const result = buildToolInputSchema({ type: 'object' });645expect(result.properties).toEqual({});646});647648test('overrides default properties when provided in schema', function () {649const props = { name: { type: 'string' } };650const result = buildToolInputSchema({ type: 'object', properties: props });651expect(result.properties).toEqual(props);652});653654test('passes through a plain schema without $schema unchanged', function () {655const schema = {656type: 'object',657properties: { id: { type: 'number' } },658required: ['id'],659};660const result = buildToolInputSchema(schema);661expect(result).toEqual(schema);662});663});664665suite('addLastTwoMessagesCacheControl', function () {666667function makeMessages(...msgs: MessageParam[]): MessageParam[] {668return msgs;669}670671function makeTool(name: string, deferred = false): AnthropicMessagesTool {672return {673name,674description: `${name} tool`,675input_schema: { type: 'object', properties: {}, required: [] },676...(deferred ? { defer_loading: true } : {}),677};678}679680function getCacheControl(block: ContentBlockParam): { type: string } | undefined {681return 'cache_control' in block ? (block as { cache_control?: { type: string } }).cache_control : undefined;682}683684function countAllCacheControl(messages: MessageParam[], system?: TextBlockParam[]): number {685let count = 0;686if (system) {687for (const block of system) {688if (block.cache_control) {689count++;690}691}692}693for (const msg of messages) {694if (Array.isArray(msg.content)) {695for (const block of msg.content) {696if (typeof block === 'object' && 'cache_control' in block && block.cache_control) {697count++;698}699}700}701}702return count;703}704705test('marks last two messages in a normal agentic loop', function () {706const messages = makeMessages(707{ role: 'user', content: [{ type: 'text', text: 'edit my file' }] as ContentBlockParam[] },708{ role: 'assistant', content: [{ type: 'text', text: 'calling tool' }, { type: 'tool_use', id: 'toolu_1', name: 'read_file', input: {} }] as ContentBlockParam[] },709{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'file contents' }] }] as ContentBlockParam[] },710);711const messagesResult = { messages };712713addLastTwoMessagesCacheControl(messagesResult);714715const assistantContent = messages[1].content as ContentBlockParam[];716expect(getCacheControl(assistantContent[assistantContent.length - 1])).toEqual({ type: 'ephemeral' });717718const toolResult = (messages[2].content as ContentBlockParam[])[0] as ToolResultBlockParam;719expect(toolResult.cache_control).toEqual({ type: 'ephemeral' });720721expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toBeUndefined();722expect(countAllCacheControl(messages)).toBe(2);723});724725test('marks last two messages in plain chat', function () {726const messages = makeMessages(727{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },728{ role: 'assistant', content: [{ type: 'text', text: 'hi there' }] as ContentBlockParam[] },729);730const messagesResult = { messages };731732addLastTwoMessagesCacheControl(messagesResult);733734expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });735expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });736expect(countAllCacheControl(messages)).toBe(2);737});738739test('handles single message', function () {740const messages = makeMessages(741{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },742);743const messagesResult = { messages };744745addLastTwoMessagesCacheControl(messagesResult);746747expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });748expect(countAllCacheControl(messages)).toBe(1);749});750751test('handles empty messages array', function () {752const messagesResult = { messages: [] as MessageParam[] };753754addLastTwoMessagesCacheControl(messagesResult);755756expect(messagesResult.messages).toHaveLength(0);757});758759test('skips thinking and redacted_thinking blocks', function () {760const messages = makeMessages(761{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },762{763role: 'assistant', content: [764{ type: 'thinking', thinking: 'hmm', signature: 'sig' },765{ type: 'text', text: 'response' },766] as ContentBlockParam[]767},768);769const messagesResult = { messages };770771addLastTwoMessagesCacheControl(messagesResult);772773const assistantContent = messages[1].content as ContentBlockParam[];774expect(getCacheControl(assistantContent[0])).toBeUndefined();775expect(getCacheControl(assistantContent[1])).toEqual({ type: 'ephemeral' });776expect(countAllCacheControl(messages)).toBe(2);777});778779test('respects max breakpoint count when some already exist', function () {780const messages = makeMessages(781{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },782{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },783{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },784{ role: 'assistant', content: [{ type: 'text', text: 'd' }] as ContentBlockParam[] },785{ role: 'user', content: [{ type: 'text', text: 'e' }] as ContentBlockParam[] },786);787const messagesResult = { messages };788789addLastTwoMessagesCacheControl(messagesResult);790791// 3 existing + 1 new = 4 total792expect(countAllCacheControl(messages)).toBe(4);793expect(getCacheControl((messages[4].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });794// Second-to-last should NOT get one — would exceed 4795expect(getCacheControl((messages[3].content as ContentBlockParam[])[0])).toBeUndefined();796});797798test('does nothing when all 4 slots are occupied', function () {799const messages = makeMessages(800{ role: 'user', content: [{ type: 'text', text: 'a', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },801{ role: 'assistant', content: [{ type: 'text', text: 'b', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },802{ role: 'user', content: [{ type: 'text', text: 'c', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },803{ role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },804{ role: 'user', content: [{ type: 'text', text: 'e' }] as ContentBlockParam[] },805);806const messagesResult = { messages };807808addLastTwoMessagesCacheControl(messagesResult);809810expect(getCacheControl((messages[4].content as ContentBlockParam[])[0])).toBeUndefined();811expect(countAllCacheControl(messages)).toBe(4);812});813814test('treats trailing message with existing cache_control as already marked', function () {815// Regression: prior code would walk past a pre-marked tail message and816// add two new markers to earlier messages, ending up with 3 distinct817// marked messages instead of 2.818const messages = makeMessages(819{ role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },820{ role: 'assistant', content: [{ type: 'text', text: 'b' }] as ContentBlockParam[] },821{ role: 'user', content: [{ type: 'text', text: 'c' }] as ContentBlockParam[] },822{ role: 'assistant', content: [{ type: 'text', text: 'd', cache_control: { type: 'ephemeral' } }] as ContentBlockParam[] },823);824const messagesResult = { messages };825826const added = addLastTwoMessagesCacheControl(messagesResult);827828expect(added).toBe(1);829expect(getCacheControl((messages[3].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });830expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });831expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toBeUndefined();832expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toBeUndefined();833expect(countAllCacheControl(messages)).toBe(2);834});835836test('does not add a second marker to a message that already has one on a non-last block', function () {837const messages = makeMessages(838{ role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },839{840role: 'assistant', content: [841{ type: 'text', text: 'first', cache_control: { type: 'ephemeral' } },842{ type: 'text', text: 'second' },843] as ContentBlockParam[]844},845);846const messagesResult = { messages };847848const added = addLastTwoMessagesCacheControl(messagesResult);849850// Last message already counts as marked; only the prior message gets a new marker.851expect(added).toBe(1);852const assistantContent = messages[1].content as ContentBlockParam[];853expect(getCacheControl(assistantContent[0])).toEqual({ type: 'ephemeral' });854expect(getCacheControl(assistantContent[1])).toBeUndefined();855expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });856expect(countAllCacheControl(messages)).toBe(2);857});858859test('marks assistant-with-tool-calls as fork point', function () {860const messages = makeMessages(861{ role: 'user', content: [{ type: 'text', text: 'do stuff' }] as ContentBlockParam[] },862{863role: 'assistant', content: [864{ type: 'text', text: 'I will call tools' },865{ type: 'tool_use', id: 'toolu_a', name: 'tool_a', input: {} },866{ type: 'tool_use', id: 'toolu_b', name: 'tool_b', input: {} },867] as ContentBlockParam[]868},869{870role: 'user', content: [871{ type: 'tool_result', tool_use_id: 'toolu_a', content: [{ type: 'text', text: 'result a' }] },872{ type: 'tool_result', tool_use_id: 'toolu_b', content: [{ type: 'text', text: 'result b' }] },873] as ContentBlockParam[]874},875);876const messagesResult = { messages };877878addLastTwoMessagesCacheControl(messagesResult);879880const assistantContent = messages[1].content as ContentBlockParam[];881expect(getCacheControl(assistantContent[2])).toEqual({ type: 'ephemeral' });882883const userContent = messages[2].content as ContentBlockParam[];884expect(getCacheControl(userContent[1])).toEqual({ type: 'ephemeral' });885886expect(countAllCacheControl(messages)).toBe(2);887});888889test('counts system block breakpoints toward the limit', function () {890const system: TextBlockParam[] = [891{ type: 'text', text: 'system', cache_control: { type: 'ephemeral' } },892];893const messages = makeMessages(894{ role: 'user', content: [{ type: 'text', text: 'a' }] as ContentBlockParam[] },895{ role: 'assistant', content: [{ type: 'text', text: 'b' }] as ContentBlockParam[] },896{ role: 'user', content: [{ type: 'text', text: 'c' }] as ContentBlockParam[] },897);898const messagesResult = { messages, system };899900addLastTwoMessagesCacheControl(messagesResult);901902// 1 system + 2 message breakpoints = 3 total903expect(countAllCacheControl(messages, system)).toBe(3);904expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });905expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });906});907908test('skips tail message with empty content and marks two prior', function () {909const messages = makeMessages(910{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },911{ role: 'assistant', content: [{ type: 'text', text: 'response' }] as ContentBlockParam[] },912{ role: 'user', content: [] as ContentBlockParam[] },913);914const messagesResult = { messages };915916addLastTwoMessagesCacheControl(messagesResult);917918expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });919expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });920expect(countAllCacheControl(messages)).toBe(2);921});922923test('skips thinking-only tail message and marks two prior', function () {924const messages = makeMessages(925{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },926{ role: 'assistant', content: [{ type: 'text', text: 'first response' }] as ContentBlockParam[] },927{928role: 'assistant', content: [929{ type: 'thinking', thinking: 'deep thought', signature: 'sig' },930{ type: 'redacted_thinking', data: 'redacted' },931] as ContentBlockParam[]932},933);934const messagesResult = { messages };935936addLastTwoMessagesCacheControl(messagesResult);937938// Thinking-only message has no cacheable blocks — skip it939expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });940expect(getCacheControl((messages[1].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });941expect(countAllCacheControl(messages)).toBe(2);942});943944test('skips empty middle message and still finds two cacheable', function () {945const messages = makeMessages(946{ role: 'user', content: [{ type: 'text', text: 'hello' }] as ContentBlockParam[] },947{ role: 'assistant', content: [] as ContentBlockParam[] },948{ role: 'user', content: [{ type: 'text', text: 'follow up' }] as ContentBlockParam[] },949);950const messagesResult = { messages };951952addLastTwoMessagesCacheControl(messagesResult);953954// Last message + first message (middle is empty, skipped)955expect(getCacheControl((messages[2].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });956expect(getCacheControl((messages[0].content as ContentBlockParam[])[0])).toEqual({ type: 'ephemeral' });957expect(countAllCacheControl(messages)).toBe(2);958});959960test('round-trip with addToolsAndSystemCacheControl produces exactly 4 markers', function () {961const tools = [makeTool('read_file'), makeTool('edit_file')];962const system: TextBlockParam[] = [{ type: 'text', text: 'You are a helpful assistant.' }];963const messages = makeMessages(964{ role: 'user', content: [{ type: 'text', text: 'edit my file' }] as ContentBlockParam[] },965{ role: 'assistant', content: [{ type: 'text', text: 'calling tool' }, { type: 'tool_use', id: 'toolu_1', name: 'read_file', input: {} }] as ContentBlockParam[] },966{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'file contents' }] }] as ContentBlockParam[] },967);968const messagesResult = { messages, system };969970// Call both in the same order as createMessagesRequestBody971addLastTwoMessagesCacheControl(messagesResult);972addToolsAndSystemCacheControl(tools, messagesResult);973974// 2 message breakpoints + 1 tool + 1 system = 4975let totalCount = countAllCacheControl(messages, system);976for (const tool of tools) {977if (tool.cache_control) {978totalCount++;979}980}981expect(totalCount).toBe(4);982983// Verify positions984const assistantContent = messages[1].content as ContentBlockParam[];985expect(getCacheControl(assistantContent[assistantContent.length - 1])).toEqual({ type: 'ephemeral' });986expect(((messages[2].content as ContentBlockParam[])[0] as ToolResultBlockParam).cache_control).toEqual({ type: 'ephemeral' });987expect(tools[1].cache_control).toEqual({ type: 'ephemeral' });988expect(system[0].cache_control).toEqual({ type: 'ephemeral' });989});990});991992describe('createMessagesRequestBody reasoning effort', () => {993let disposables: DisposableStore;994let instantiationService: IInstantiationService;995996function createMockEndpoint(overrides: Partial<IChatEndpoint> = {}): IChatEndpoint {997return {998model: 'claude-sonnet-4.5',999family: 'claude-sonnet-4.5',1000modelProvider: 'Anthropic',1001maxOutputTokens: 8192,1002modelMaxPromptTokens: 200000,1003supportsToolCalls: true,1004supportsVision: true,1005supportsPrediction: false,1006showInModelPicker: true,1007isFallback: false,1008name: 'test',1009version: '1.0',1010policy: 'enabled',1011urlOrRequestMetadata: 'https://test.com',1012tokenizer: 0,1013isDefault: false,1014processResponseFromChatEndpoint: () => { throw new Error('not implemented'); },1015acceptChatPolicy: () => { throw new Error('not implemented'); },1016makeChatRequest2: () => { throw new Error('not implemented'); },1017createRequestBody: () => { throw new Error('not implemented'); },1018cloneWithTokenOverride: () => { throw new Error('not implemented'); },1019interceptBody: () => { },1020getExtraHeaders: () => ({}),1021...overrides,1022} as IChatEndpoint;1023}10241025function createMinimalOptions(overrides: Partial<ICreateEndpointBodyOptions> = {}): ICreateEndpointBodyOptions {1026return {1027debugName: 'test',1028requestId: 'test-request-id',1029finishedCb: undefined,1030messages: [{1031role: Raw.ChatRole.User,1032content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }],1033}],1034postOptions: { max_tokens: 8192 },1035location: ChatLocation.Panel,1036...overrides,1037};1038}10391040beforeEach(() => {1041disposables = new DisposableStore();1042const services = disposables.add(createPlatformServices(disposables));1043services.define(IToolDeferralService, {1044_serviceBrand: undefined,1045isNonDeferredTool: () => true,1046});1047const accessor = services.createTestingAccessor();1048instantiationService = accessor.get(IInstantiationService);1049});10501051test('includes effort in output_config when model supports reasoning effort and thinking is adaptive', () => {1052const endpoint = createMockEndpoint({1053supportsAdaptiveThinking: true,1054supportsReasoningEffort: ['low', 'medium', 'high'],1055});1056const options = createMinimalOptions({1057modelCapabilities: { enableThinking: true, reasoningEffort: 'high' },1058});10591060const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);10611062expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });1063expect(body.output_config).toEqual({ effort: 'high' });1064});10651066test('omits effort when model does not declare supportsReasoningEffort', () => {1067const endpoint = createMockEndpoint({1068supportsAdaptiveThinking: true,1069// supportsReasoningEffort is undefined1070});1071const options = createMinimalOptions({1072modelCapabilities: { enableThinking: true, reasoningEffort: 'high' },1073});10741075const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);10761077expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });1078expect(body.output_config).toBeUndefined();1079});10801081test('omits effort when supportsReasoningEffort is an empty array', () => {1082const endpoint = createMockEndpoint({1083supportsAdaptiveThinking: true,1084supportsReasoningEffort: [],1085});1086const options = createMinimalOptions({1087modelCapabilities: { enableThinking: true, reasoningEffort: 'medium' },1088});10891090const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);10911092expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });1093expect(body.output_config).toBeUndefined();1094});10951096test('omits effort when thinking is not enabled', () => {1097const endpoint = createMockEndpoint({1098supportsAdaptiveThinking: true,1099supportsReasoningEffort: ['low', 'medium', 'high'],1100});1101const options = createMinimalOptions({1102modelCapabilities: { enableThinking: false, reasoningEffort: 'high' },1103});11041105const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);11061107expect(body.thinking).toBeUndefined();1108expect(body.output_config).toBeUndefined();1109});11101111test('omits effort when reasoningEffort is an invalid value', () => {1112const endpoint = createMockEndpoint({1113supportsAdaptiveThinking: true,1114supportsReasoningEffort: ['low', 'medium', 'high'],1115});1116const options = createMinimalOptions({1117modelCapabilities: { enableThinking: true, reasoningEffort: 'xhigh' as any },1118});11191120const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);11211122expect(body.thinking).toEqual({ type: 'adaptive', display: 'summarized' });1123expect(body.output_config).toBeUndefined();1124});11251126test('uses budget_tokens thinking when model has maxThinkingBudget but not adaptive', () => {1127const endpoint = createMockEndpoint({1128supportsAdaptiveThinking: false,1129maxThinkingBudget: 32000,1130minThinkingBudget: 1024,1131supportsReasoningEffort: ['low', 'medium', 'high'],1132});1133const options = createMinimalOptions({1134modelCapabilities: { enableThinking: true, reasoningEffort: 'low' },1135});11361137const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);11381139expect(body.thinking).toEqual({ type: 'enabled', budget_tokens: 8191 });1140expect(body.output_config).toEqual({ effort: 'low' });1141});1142});11431144describe('createMessagesRequestBody tool search deferral', () => {1145let disposables: DisposableStore;1146let instantiationService: IInstantiationService;11471148function createMockEndpoint(supportsToolSearch: boolean): IChatEndpoint {1149return {1150model: 'claude-sonnet-4.6',1151family: 'claude-sonnet-4.6',1152modelProvider: 'Anthropic',1153maxOutputTokens: 8192,1154modelMaxPromptTokens: 200000,1155supportsToolCalls: true,1156supportsVision: true,1157supportsPrediction: false,1158supportsToolSearch,1159showInModelPicker: true,1160isFallback: false,1161name: 'test',1162version: '1.0',1163policy: 'enabled',1164urlOrRequestMetadata: 'https://test.com',1165tokenizer: 0,1166isDefault: false,1167processResponseFromChatEndpoint: () => { throw new Error('not implemented'); },1168acceptChatPolicy: () => { throw new Error('not implemented'); },1169makeChatRequest2: () => { throw new Error('not implemented'); },1170createRequestBody: () => { throw new Error('not implemented'); },1171cloneWithTokenOverride: () => { throw new Error('not implemented'); },1172interceptBody: () => { },1173getExtraHeaders: () => ({}),1174} as unknown as IChatEndpoint;1175}11761177function makeTool(name: string) {1178return { type: 'function' as const, function: { name, description: `${name} tool`, parameters: { type: 'object', properties: {} } } };1179}11801181function createOptions(tools: ReturnType<typeof makeTool>[]): ICreateEndpointBodyOptions {1182return {1183debugName: 'test',1184requestId: 'test-request-id',1185finishedCb: undefined,1186messages: [{1187role: Raw.ChatRole.User,1188content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }],1189}],1190postOptions: { max_tokens: 8192 },1191location: ChatLocation.Agent,1192modelCapabilities: { enableToolSearch: true },1193requestOptions: { tools },1194} as ICreateEndpointBodyOptions;1195}11961197beforeEach(() => {1198disposables = new DisposableStore();1199const services = disposables.add(createPlatformServices(disposables));1200// Non-deferred allowlist matches production: core tools + tool_search itself.1201const nonDeferred = new Set(['read_file', 'grep_search', CUSTOM_TOOL_SEARCH_NAME]);1202services.define(IToolDeferralService, {1203_serviceBrand: undefined,1204isNonDeferredTool: (name: string) => nonDeferred.has(name),1205});1206const accessor = services.createTestingAccessor();1207instantiationService = accessor.get(IInstantiationService);1208});12091210test('does not set defer_loading when tool_search is not in the request tool list', () => {1211// Repro for https://github.com/microsoft/vscode/issues/311946: a custom agent1212// with `tools: ['my-mcp-server/*']` filters out tool_search. Without this gate,1213// every MCP tool gets defer_loading=true and Anthropic rejects the request with1214// "At least one tool must have defer_loading=false."1215const endpoint = createMockEndpoint(true);1216const options = createOptions([makeTool('some_mcp_tool'), makeTool('another_mcp_tool')]);12171218const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);12191220const tools = body.tools as AnthropicMessagesTool[];1221expect(tools.every(t => !t.defer_loading)).toBe(true);1222expect(tools.find(t => t.name === 'some_mcp_tool')).toBeDefined();1223expect(tools.find(t => t.name === 'another_mcp_tool')).toBeDefined();1224});12251226test('defers MCP tools when tool_search is in the request tool list', () => {1227const endpoint = createMockEndpoint(true);1228const options = createOptions([1229makeTool('read_file'),1230makeTool('some_mcp_tool'),1231makeTool(CUSTOM_TOOL_SEARCH_NAME),1232]);12331234const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);12351236const tools = body.tools as AnthropicMessagesTool[];1237expect(tools.find(t => t.name === 'read_file')?.defer_loading).toBeUndefined();1238expect(tools.find(t => t.name === CUSTOM_TOOL_SEARCH_NAME)?.defer_loading).toBeUndefined();1239expect(tools.find(t => t.name === 'some_mcp_tool')?.defer_loading).toBe(true);1240});12411242test('does not defer when endpoint does not support tool search', () => {1243const endpoint = createMockEndpoint(false);1244const options = createOptions([makeTool('read_file'), makeTool('some_mcp_tool'), makeTool(CUSTOM_TOOL_SEARCH_NAME)]);12451246const body = instantiationService.invokeFunction(createMessagesRequestBody, options, endpoint.model, endpoint);12471248const tools = body.tools as AnthropicMessagesTool[];1249expect(tools.every(t => !t.defer_loading)).toBe(true);1250});1251});125212531254