Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatHistoryBuilder.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 Anthropic from '@anthropic-ai/sdk';6import { describe, expect, it } from 'vitest';7import type * as vscode from 'vscode';8import { URI } from '../../../../util/vs/base/common/uri';9import { ChatReferenceBinaryData, ChatRequestTurn, ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes';10import { IClaudeCodeSession, ISubagentSession, StoredMessage, SYNTHETIC_MODEL_ID } from '../../claude/node/sessionParser/claudeSessionSchema';11import { buildChatHistory } from '../chatHistoryBuilder';1213// #region Test Helpers1415let _msgCounter = 0;1617function userMsg(content: string | Anthropic.Messages.ContentBlockParam[]): StoredMessage {18const uuid = `user-${++_msgCounter}`;19return {20uuid,21sessionId: 'test-session',22timestamp: new Date(),23parentUuid: null,24type: 'user',25message: { role: 'user' as const, content },26} as StoredMessage;27}2829function assistantMsg(content: readonly Record<string, unknown>[], model = 'claude-3-sonnet'): StoredMessage {30const uuid = `asst-${++_msgCounter}`;31return {32uuid,33sessionId: 'test-session',34timestamp: new Date(),35parentUuid: null,36type: 'assistant',37message: {38id: uuid,39type: 'message',40role: 'assistant' as const,41content,42model,43stop_reason: content.some(b => b.type === 'tool_use') ? 'tool_use' : 'end_turn',44stop_sequence: null,45usage: { input_tokens: 10, output_tokens: 10 },46},47} as StoredMessage;48}4950function toolResult(toolUseId: string, content: string, isError = false): StoredMessage {51return userMsg([{ type: 'tool_result', tool_use_id: toolUseId, content, is_error: isError }]);52}5354function session(messages: StoredMessage[], subagents: ISubagentSession[] = []): IClaudeCodeSession {55const timestamp = new Date();56return {57id: 'test-session',58label: 'Test',59messages,60created: (messages[0]?.timestamp ?? timestamp).getTime(),61lastRequestEnded: (messages[messages.length - 1]?.timestamp ?? timestamp).getTime(),62subagents,63};64}6566interface SnapshotRequest {67type: 'request';68prompt: string;69}7071interface SnapshotResponse {72type: 'response';73parts: Array<Record<string, unknown>>;74}7576type SnapshotTurn = SnapshotRequest | SnapshotResponse | { type: 'unknown' };7778function getResponseParts(snapshot: SnapshotTurn[], index: number): Array<Record<string, unknown>> {79const turn = snapshot[index];80if (turn.type !== 'response') {81throw new Error(`Expected response at index ${index}, got ${turn.type}`);82}83return turn.parts;84}8586function mapHistoryForSnapshot(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn2)[]): SnapshotTurn[] {87return history.map(turn => {88if (turn instanceof ChatRequestTurn || turn instanceof ChatRequestTurn2) {89return {90type: 'request',91prompt: turn.prompt,92};93} else if (turn instanceof ChatResponseTurn2) {94return {95type: 'response',96parts: turn.response.map(part => {97if (part instanceof ChatResponseMarkdownPart) {98return {99type: 'markdown',100content: part.value.value,101};102} else if (part instanceof ChatToolInvocationPart) {103return {104type: 'tool',105toolName: part.toolName,106toolCallId: part.toolCallId,107isError: part.isError,108isComplete: part.isComplete,109};110} else if (part instanceof ChatResponseThinkingProgressPart) {111return {112type: 'thinking',113};114}115return { type: 'unknown' };116}),117};118}119return { type: 'unknown' };120});121}122123// #endregion124125describe('buildChatHistory', () => {126127// #region Empty and Minimal Cases128129describe('empty and minimal cases', () => {130it('returns empty array for session with no messages', () => {131const result = buildChatHistory(session([]));132expect(result).toEqual([]);133});134135it('converts a single user message to a request turn', () => {136const result = buildChatHistory(session([137userMsg('Hello'),138]));139expect(mapHistoryForSnapshot(result)).toMatchInlineSnapshot(`140[141{142"prompt": "Hello",143"type": "request",144},145]146`);147});148149it('converts a single assistant text message to a response turn', () => {150const result = buildChatHistory(session([151assistantMsg([{ type: 'text', text: 'Hi there!' }]),152]));153expect(mapHistoryForSnapshot(result)).toMatchInlineSnapshot(`154[155{156"parts": [157{158"content": "Hi there!",159"type": "markdown",160},161],162"type": "response",163},164]165`);166});167});168169// #endregion170171// #region Simple Request/Response Pairs172173describe('simple request/response pairs', () => {174it('converts a user message followed by an assistant text response', () => {175const result = buildChatHistory(session([176userMsg('What is 2+2?'),177assistantMsg([{ type: 'text', text: 'The answer is 4.' }]),178]));179expect(mapHistoryForSnapshot(result)).toMatchInlineSnapshot(`180[181{182"prompt": "What is 2+2?",183"type": "request",184},185{186"parts": [187{188"content": "The answer is 4.",189"type": "markdown",190},191],192"type": "response",193},194]195`);196});197198it('handles multiple conversation turns', () => {199const result = buildChatHistory(session([200userMsg('First question'),201assistantMsg([{ type: 'text', text: 'First answer' }]),202userMsg('Second question'),203assistantMsg([{ type: 'text', text: 'Second answer' }]),204]));205expect(result).toHaveLength(4);206expect(result[0]).toBeInstanceOf(ChatRequestTurn2);207expect(result[1]).toBeInstanceOf(ChatResponseTurn2);208expect(result[2]).toBeInstanceOf(ChatRequestTurn2);209expect(result[3]).toBeInstanceOf(ChatResponseTurn2);210});211});212213// #endregion214215// #region Consecutive Message Grouping216217describe('consecutive message grouping', () => {218it('combines consecutive user messages into a single request turn', () => {219const result = buildChatHistory(session([220userMsg('First part.'),221userMsg('Second part.'),222assistantMsg([{ type: 'text', text: 'Response' }]),223]));224const snapshot = mapHistoryForSnapshot(result);225expect(snapshot).toHaveLength(2);226expect(snapshot[0]).toEqual({227type: 'request',228prompt: 'First part.\n\nSecond part.',229});230});231232it('combines consecutive assistant messages into a single response turn', () => {233const result = buildChatHistory(session([234userMsg('Hello'),235assistantMsg([{ type: 'text', text: 'Part one.' }]),236assistantMsg([{ type: 'text', text: 'Part two.' }]),237]));238const snapshot = mapHistoryForSnapshot(result);239expect(snapshot).toHaveLength(2);240expect(snapshot[1]).toEqual({241type: 'response',242parts: [243{ type: 'markdown', content: 'Part one.' },244{ type: 'markdown', content: 'Part two.' },245],246});247});248});249250// #endregion251252// #region Single Tool Call253254describe('single tool call', () => {255it('creates a tool invocation part for tool_use blocks', () => {256const result = buildChatHistory(session([257userMsg('List files'),258assistantMsg([259{ type: 'text', text: 'Let me check.' },260{ type: 'tool_use', id: 'tool-1', name: 'bash', input: { command: 'ls' } },261]),262]));263const snapshot = mapHistoryForSnapshot(result);264expect(snapshot[1]).toEqual({265type: 'response',266parts: [267{ type: 'markdown', content: 'Let me check.' },268{ type: 'tool', toolName: 'bash', toolCallId: 'tool-1', isComplete: undefined },269],270});271});272273it('marks tool invocations as complete when tool result follows', () => {274const result = buildChatHistory(session([275userMsg('List files'),276assistantMsg([277{ type: 'tool_use', id: 'tool-1', name: 'bash', input: { command: 'ls' } },278]),279toolResult('tool-1', 'file1.txt\nfile2.txt'),280]));281const snapshot = mapHistoryForSnapshot(result);282// Should be a single response with a completed tool283expect(snapshot).toHaveLength(2);284expect(snapshot[1]).toEqual({285type: 'response',286parts: [287{ type: 'tool', toolName: 'bash', toolCallId: 'tool-1', isError: false, isComplete: true },288],289});290});291292it('marks tool invocations as error when tool result is an error', () => {293const result = buildChatHistory(session([294userMsg('Run command'),295assistantMsg([296{ type: 'tool_use', id: 'tool-1', name: 'bash', input: { command: 'bad-cmd' } },297]),298toolResult('tool-1', 'command not found', true),299]));300const snapshot = mapHistoryForSnapshot(result);301expect(getResponseParts(snapshot, 1)[0]).toMatchObject({302type: 'tool',303isError: true,304isComplete: true,305});306});307});308309// #endregion310311// #region Multi-Round Tool Use (Core Bug Fix)312313describe('multi-round tool use merging', () => {314it('merges assistant → tool_result → assistant into a single response', () => {315const result = buildChatHistory(session([316userMsg('Find and read config'),317assistantMsg([318{ type: 'text', text: 'Let me find it.' },319{ type: 'tool_use', id: 'tool-1', name: 'Glob', input: { pattern: '**/config.*' } },320]),321toolResult('tool-1', 'config.json'),322assistantMsg([323{ type: 'text', text: 'Found it. Let me read it.' },324{ type: 'tool_use', id: 'tool-2', name: 'Read', input: { file_path: 'config.json' } },325]),326toolResult('tool-2', '{ "key": "value" }'),327assistantMsg([328{ type: 'text', text: 'Done.' },329]),330]));331332const snapshot = mapHistoryForSnapshot(result);333// Must be exactly 1 request + 1 response334expect(snapshot).toHaveLength(2);335expect(snapshot[0].type).toBe('request');336expect(snapshot[1].type).toBe('response');337expect(getResponseParts(snapshot, 1)).toHaveLength(5);338});339340it('merges many rounds of tool use into a single response', () => {341const result = buildChatHistory(session([342userMsg('Do complex task'),343assistantMsg([{ type: 'tool_use', id: 't1', name: 'Glob', input: {} }]),344toolResult('t1', 'result1'),345assistantMsg([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }]),346toolResult('t2', 'result2'),347assistantMsg([{ type: 'tool_use', id: 't3', name: 'Grep', input: {} }]),348toolResult('t3', 'result3'),349assistantMsg([{ type: 'tool_use', id: 't4', name: 'bash', input: {} }]),350toolResult('t4', 'result4'),351assistantMsg([{ type: 'text', text: 'All done.' }]),352]));353354const snapshot = mapHistoryForSnapshot(result);355expect(snapshot).toHaveLength(2);356expect(getResponseParts(snapshot, 1)).toHaveLength(5); // 4 tools + 1 text357expect(getResponseParts(snapshot, 1)[0]).toMatchObject({ type: 'tool', isComplete: true });358expect(getResponseParts(snapshot, 1)[1]).toMatchObject({ type: 'tool', isComplete: true });359expect(getResponseParts(snapshot, 1)[2]).toMatchObject({ type: 'tool', isComplete: true });360expect(getResponseParts(snapshot, 1)[3]).toMatchObject({ type: 'tool', isComplete: true });361expect(getResponseParts(snapshot, 1)[4]).toMatchObject({ type: 'markdown', content: 'All done.' });362});363364it('correctly separates two user requests each with their own tool loops', () => {365const result = buildChatHistory(session([366// First user request with tool loop367userMsg('First task'),368assistantMsg([{ type: 'tool_use', id: 't1', name: 'Glob', input: {} }]),369toolResult('t1', 'found'),370assistantMsg([{ type: 'text', text: 'Done with first.' }]),371// Second user request with tool loop372userMsg('Second task'),373assistantMsg([{ type: 'tool_use', id: 't2', name: 'Read', input: {} }]),374toolResult('t2', 'content'),375assistantMsg([{ type: 'text', text: 'Done with second.' }]),376]));377378const snapshot = mapHistoryForSnapshot(result);379expect(snapshot).toHaveLength(4); // req, resp, req, resp380expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'First task' });381expect(snapshot[1]).toMatchObject({ type: 'response' });382expect(getResponseParts(snapshot, 1)).toHaveLength(2); // tool + text383expect(snapshot[2]).toMatchObject({ type: 'request', prompt: 'Second task' });384expect(snapshot[3]).toMatchObject({ type: 'response' });385expect(getResponseParts(snapshot, 3)).toHaveLength(2); // tool + text386});387388it('handles parallel tool calls in a single assistant message', () => {389const result = buildChatHistory(session([390userMsg('Search broadly'),391assistantMsg([392{ type: 'text', text: 'Searching...' },393{ type: 'tool_use', id: 't1', name: 'Glob', input: {} },394{ type: 'tool_use', id: 't2', name: 'Grep', input: {} },395]),396// Both tool results come in the same user message397userMsg([398{ type: 'tool_result', tool_use_id: 't1', content: 'glob result' },399{ type: 'tool_result', tool_use_id: 't2', content: 'grep result' },400]),401assistantMsg([{ type: 'text', text: 'Found everything.' }]),402]));403404const snapshot = mapHistoryForSnapshot(result);405expect(snapshot).toHaveLength(2);406expect(getResponseParts(snapshot, 1)).toHaveLength(4); // text + 2 tools + text407expect(getResponseParts(snapshot, 1)[1]).toMatchObject({ type: 'tool', isComplete: true });408expect(getResponseParts(snapshot, 1)[2]).toMatchObject({ type: 'tool', isComplete: true });409});410411it('handles tool results that arrive in separate user messages', () => {412const result = buildChatHistory(session([413userMsg('Do thing'),414assistantMsg([415{ type: 'tool_use', id: 't1', name: 'Glob', input: {} },416{ type: 'tool_use', id: 't2', name: 'Grep', input: {} },417]),418// Each tool result as a separate user message (both should be merged)419toolResult('t1', 'glob result'),420toolResult('t2', 'grep result'),421assistantMsg([{ type: 'text', text: 'Done.' }]),422]));423424const snapshot = mapHistoryForSnapshot(result);425expect(snapshot).toHaveLength(2);426expect(getResponseParts(snapshot, 1)[0]).toMatchObject({ type: 'tool', isComplete: true });427expect(getResponseParts(snapshot, 1)[1]).toMatchObject({ type: 'tool', isComplete: true });428});429});430431// #endregion432433// #region System Reminder Filtering434435describe('system reminder filtering', () => {436it('filters out system-reminder blocks from user messages', () => {437const result = buildChatHistory(session([438userMsg([439{ type: 'text', text: '<system-reminder>\nInternal context.\n</system-reminder>' },440{ type: 'text', text: 'What does this do?' },441]),442]));443const snapshot = mapHistoryForSnapshot(result);444expect(snapshot).toHaveLength(1);445expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'What does this do?' });446});447448it('strips system-reminders from legacy string format', () => {449const result = buildChatHistory(session([450userMsg('<system-reminder>\nInternal.\n</system-reminder>\n\nActual question'),451]));452const snapshot = mapHistoryForSnapshot(result);453expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Actual question' });454});455456it('produces no request turn when user message is only a system-reminder', () => {457const result = buildChatHistory(session([458userMsg([459{ type: 'text', text: '<system-reminder>\nInternal.\n</system-reminder>' },460]),461assistantMsg([{ type: 'text', text: 'Hello!' }]),462]));463const snapshot = mapHistoryForSnapshot(result);464// Only the assistant response should appear465expect(snapshot).toHaveLength(1);466expect(snapshot[0]).toMatchObject({ type: 'response' });467});468469it('filters system-reminder user messages mid-tool-loop without breaking the response', () => {470const result = buildChatHistory(session([471userMsg('Do task'),472assistantMsg([473{ type: 'tool_use', id: 't1', name: 'bash', input: {} },474]),475// Tool result + system reminder in the same user message group476userMsg([477{ type: 'tool_result', tool_use_id: 't1', content: 'done' },478]),479userMsg([480{ type: 'text', text: '<system-reminder>\nReminder.\n</system-reminder>' },481]),482assistantMsg([{ type: 'text', text: 'Finished.' }]),483]));484485const snapshot = mapHistoryForSnapshot(result);486// System-reminder-only user messages should not break the response487expect(snapshot).toHaveLength(2);488expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Do task' });489expect(getResponseParts(snapshot, 1)).toHaveLength(2); // tool + text490});491});492493// #endregion494495// #region Interrupted Requests496497describe('interrupted requests', () => {498it('skips user messages that are interruption markers', () => {499const result = buildChatHistory(session([500userMsg('Do something'),501assistantMsg([502{ type: 'tool_use', id: 't1', name: 'bash', input: {} },503]),504toolResult('t1', 'partial'),505assistantMsg([{ type: 'text', text: 'Working...' }]),506userMsg('[Request interrupted by user]'),507assistantMsg([{ type: 'text', text: 'Stopped.' }]),508]));509510const snapshot = mapHistoryForSnapshot(result);511// The interruption marker should not create a request turn512// The "Stopped." response merges into a new response (since the interrupted513// user message broke the assistant grouping but produced no request turn)514expect(snapshot.filter(s => s.type === 'request')).toHaveLength(1);515});516});517518// #endregion519520// #region Thinking Blocks521522describe('thinking blocks', () => {523it('includes thinking blocks in response parts', () => {524const result = buildChatHistory(session([525userMsg('Think about this'),526assistantMsg([527{ type: 'thinking', thinking: 'Let me reason...' },528{ type: 'text', text: 'Here is my answer.' },529]),530]));531532// Thinking block + text = 2 parts533expect(result).toHaveLength(2);534const response = result[1] as vscode.ChatResponseTurn2;535expect(response.response).toHaveLength(2);536});537538it('preserves thinking blocks across multi-round tool use', () => {539const result = buildChatHistory(session([540userMsg('Complex task'),541assistantMsg([542{ type: 'thinking', thinking: 'First thinking...' },543{ type: 'tool_use', id: 't1', name: 'Glob', input: {} },544]),545toolResult('t1', 'found'),546assistantMsg([547{ type: 'thinking', thinking: 'Second thinking...' },548{ type: 'text', text: 'Done.' },549]),550]));551552const snapshot = mapHistoryForSnapshot(result);553expect(snapshot).toHaveLength(2); // 1 request, 1 merged response554});555});556557// #endregion558559// #region Edge Cases560561describe('edge cases', () => {562it('handles tool_use without a corresponding tool_result', () => {563const result = buildChatHistory(session([564userMsg('Start'),565assistantMsg([566{ type: 'tool_use', id: 't1', name: 'bash', input: {} },567]),568// No tool result - session may have been interrupted569]));570571const snapshot = mapHistoryForSnapshot(result);572expect(snapshot).toHaveLength(2);573expect(getResponseParts(snapshot, 1)[0]).toMatchObject({574type: 'tool',575isComplete: undefined, // Not completed since no result arrived576});577});578579it('handles user message with mixed text and tool_result content', () => {580const result = buildChatHistory(session([581userMsg([582{ type: 'text', text: 'Here is context: ' },583{ type: 'tool_result', tool_use_id: 'orphan', content: 'result', is_error: false },584]),585]));586587const snapshot = mapHistoryForSnapshot(result);588// The text should become a request; the tool_result is processed (but orphaned)589expect(snapshot).toHaveLength(1);590expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Here is context: ' });591});592593it('handles session starting with assistant message (no preceding user message)', () => {594const result = buildChatHistory(session([595assistantMsg([{ type: 'text', text: 'I was already running.' }]),596]));597598const snapshot = mapHistoryForSnapshot(result);599expect(snapshot).toHaveLength(1);600expect(snapshot[0]).toMatchObject({ type: 'response' });601});602603it('handles tool_result for a tool_use_id that does not exist', () => {604const result = buildChatHistory(session([605userMsg('Start'),606assistantMsg([{ type: 'text', text: 'Response' }]),607toolResult('nonexistent-id', 'result'),608]));609610// Should not throw, the orphaned tool result is just ignored611const snapshot = mapHistoryForSnapshot(result);612expect(snapshot).toHaveLength(2);613});614615it('handles empty assistant content blocks', () => {616const result = buildChatHistory(session([617userMsg('Hello'),618assistantMsg([]),619]));620621const snapshot = mapHistoryForSnapshot(result);622// Empty content produces no parts, so no response turn is created.623// Only the request turn from the user message exists.624expect(snapshot).toHaveLength(1);625expect(snapshot[0]).toMatchObject({ type: 'request' });626});627628it('handles whitespace-only user messages', () => {629const result = buildChatHistory(session([630userMsg(' \n\t '),631assistantMsg([{ type: 'text', text: 'Response' }]),632]));633634const snapshot = mapHistoryForSnapshot(result);635// Whitespace-only should not create a request turn636expect(snapshot).toHaveLength(1);637expect(snapshot[0]).toMatchObject({ type: 'response' });638});639640it('appends system messages as separated markdown parts in the preceding response', () => {641const systemMessage: StoredMessage = {642uuid: 'sys-1',643sessionId: 'test-session',644timestamp: new Date(),645parentUuid: null,646type: 'system',647message: { role: 'system' as const, content: 'Conversation compacted' },648};649650const result = buildChatHistory(session([651userMsg('Hello'),652assistantMsg([{ type: 'text', text: 'Hi there' }]),653systemMessage,654userMsg('After compaction'),655assistantMsg([{ type: 'text', text: 'Continuing' }]),656]));657658const snapshot = mapHistoryForSnapshot(result);659// Request, Response (with system appended), Request, Response660expect(snapshot).toHaveLength(4);661expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Hello' });662expect(snapshot[1]).toMatchObject({ type: 'response' });663expect(snapshot[2]).toMatchObject({ type: 'request', prompt: 'After compaction' });664expect(snapshot[3]).toMatchObject({ type: 'response' });665666// The system message should be appended as a second markdown part with separator667const responseParts = getResponseParts(snapshot, 1);668expect(responseParts).toHaveLength(2);669expect(responseParts[0]).toMatchObject({ type: 'markdown', content: 'Hi there' });670expect(responseParts[1]).toMatchObject({ type: 'markdown', content: '\n\n---\n\n*Conversation compacted*' });671});672673it('creates a standalone response turn when system message appears with no preceding response', () => {674const systemMessage: StoredMessage = {675uuid: 'sys-1',676sessionId: 'test-session',677timestamp: new Date(),678parentUuid: null,679type: 'system',680message: { role: 'system' as const, content: 'Conversation compacted' },681};682683const result = buildChatHistory(session([684systemMessage,685userMsg('After compaction'),686assistantMsg([{ type: 'text', text: 'Continuing' }]),687]));688689const snapshot = mapHistoryForSnapshot(result);690// System response (standalone since no preceding parts), Request, Response691expect(snapshot).toHaveLength(3);692expect(snapshot[0]).toMatchObject({ type: 'response' });693expect(snapshot[1]).toMatchObject({ type: 'request', prompt: 'After compaction' });694expect(snapshot[2]).toMatchObject({ type: 'response' });695696const systemParts = getResponseParts(snapshot, 0);697expect(systemParts).toHaveLength(1);698expect(systemParts[0]).toMatchObject({ type: 'markdown', content: '\n\n---\n\n*Conversation compacted*' });699});700});701702// #endregion703704// #region Subagent Tool Calls705706describe('subagent tool calls', () => {707function subagentSession(agentId: string, messages: StoredMessage[], parentToolUseId?: string): ISubagentSession {708return {709agentId,710parentToolUseId,711messages,712timestamp: new Date(),713};714}715716it('injects subagent tool calls after Task tool result', () => {717const taskToolUseId = 'toolu_task_001';718const subagentBashId = 'toolu_bash_sub_001';719720const subagent = subagentSession('agent-abc', [721assistantMsg([{ type: 'tool_use', id: subagentBashId, name: 'Bash', input: { command: 'sleep 10' } }]),722toolResult(subagentBashId, 'command completed'),723], taskToolUseId);724725const result = buildChatHistory(session([726userMsg('run a task'),727assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Task', input: { description: 'Run sleep', prompt: 'sleep 10' } }]),728toolResult(taskToolUseId, 'Task completed'),729assistantMsg([{ type: 'text', text: 'Done!' }]),730], [subagent]));731732// Should have: request, response733expect(result).toHaveLength(2);734735const response = result[1] as vscode.ChatResponseTurn2;736const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);737738// Should have Task tool + subagent Bash tool739expect(toolParts).toHaveLength(2);740741// First tool is the Task itself742expect(toolParts[0].toolName).toBe('Task');743expect(toolParts[0].toolCallId).toBe(taskToolUseId);744expect(toolParts[0].isComplete).toBe(true);745746// Second tool is the subagent's Bash call747expect(toolParts[1].toolName).toBe('Bash');748expect(toolParts[1].toolCallId).toBe(subagentBashId);749expect(toolParts[1].subAgentInvocationId).toBe(taskToolUseId);750expect(toolParts[1].isComplete).toBe(true);751});752753it('handles Agent tool name (renamed from Task in Claude Code v2.1.63)', () => {754const agentToolUseId = 'toolu_agent_001';755const subagentBashId = 'toolu_bash_sub_agent';756757const subagent = subagentSession('agent-new', [758assistantMsg([{ type: 'tool_use', id: subagentBashId, name: 'Bash', input: { command: 'ls' } }]),759toolResult(subagentBashId, 'files listed'),760], agentToolUseId);761762const result = buildChatHistory(session([763userMsg('run an agent'),764assistantMsg([{ type: 'tool_use', id: agentToolUseId, name: 'Agent', input: { description: 'List files', prompt: 'ls' } }]),765toolResult(agentToolUseId, 'Agent completed'),766assistantMsg([{ type: 'text', text: 'Done!' }]),767], [subagent]));768769expect(result).toHaveLength(2);770771const response = result[1] as vscode.ChatResponseTurn2;772const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);773774expect(toolParts).toHaveLength(2);775expect(toolParts[0].toolName).toBe('Agent');776expect(toolParts[0].toolCallId).toBe(agentToolUseId);777expect(toolParts[1].toolName).toBe('Bash');778expect(toolParts[1].subAgentInvocationId).toBe(agentToolUseId);779});780781it('sets subAgentInvocationId on all subagent tool calls', () => {782const taskToolUseId = 'toolu_task_002';783784const subagent = subagentSession('agent-xyz', [785assistantMsg([{ type: 'tool_use', id: 'toolu_read_001', name: 'Read', input: { file_path: '/tmp/test.txt' } }]),786toolResult('toolu_read_001', 'file contents'),787assistantMsg([{ type: 'tool_use', id: 'toolu_edit_001', name: 'Edit', input: { file_path: '/tmp/test.txt', old_string: 'a', new_string: 'b' } }]),788toolResult('toolu_edit_001', 'edit applied'),789], taskToolUseId);790791const result = buildChatHistory(session([792userMsg('edit a file'),793assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Task', input: { description: 'Edit file', prompt: 'edit the file' } }]),794toolResult(taskToolUseId, 'Edits done'),795assistantMsg([{ type: 'text', text: 'All done.' }]),796], [subagent]));797798const response = result[1] as vscode.ChatResponseTurn2;799const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);800801// Task + 2 subagent tools (Read returns undefined from createFormattedToolInvocation for Edit/Write)802// Read should produce an invocation, Edit/Write return undefined803// Let's just check all subagent tools have the correct subAgentInvocationId804const subagentTools = toolParts.filter(t => t.subAgentInvocationId === taskToolUseId);805expect(subagentTools.length).toBeGreaterThan(0);806807for (const tool of subagentTools) {808expect(tool.subAgentInvocationId).toBe(taskToolUseId);809}810});811812it('handles session with no subagents (backward compatible)', () => {813const result = buildChatHistory(session([814userMsg('hello'),815assistantMsg([{ type: 'text', text: 'hi' }]),816]));817818expect(result).toHaveLength(2);819});820821it('handles Task tool with no matching subagent', () => {822const taskToolUseId = 'toolu_task_003';823824const result = buildChatHistory(session([825userMsg('run a task'),826assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Task', input: { description: 'Do something', prompt: 'do it' } }]),827toolResult(taskToolUseId, 'Task completed'),828assistantMsg([{ type: 'text', text: 'Done!' }]),829]));830831const response = result[1] as vscode.ChatResponseTurn2;832const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);833834// Only the Task tool itself, no subagent tools835expect(toolParts).toHaveLength(1);836expect(toolParts[0].toolName).toBe('Task');837});838839it('handles multiple Task tools with different subagents', () => {840const task1Id = 'toolu_task_multi_1';841const task2Id = 'toolu_task_multi_2';842843const subagent1 = subagentSession('agent-1', [844assistantMsg([{ type: 'tool_use', id: 'toolu_bash_1', name: 'Bash', input: { command: 'echo hello' } }]),845toolResult('toolu_bash_1', 'hello'),846], task1Id);847848const subagent2 = subagentSession('agent-2', [849assistantMsg([{ type: 'tool_use', id: 'toolu_bash_2', name: 'Bash', input: { command: 'echo world' } }]),850toolResult('toolu_bash_2', 'world'),851], task2Id);852853const result = buildChatHistory(session([854userMsg('run two tasks'),855assistantMsg([856{ type: 'tool_use', id: task1Id, name: 'Task', input: { description: 'Task 1', prompt: 'echo hello' } },857{ type: 'tool_use', id: task2Id, name: 'Task', input: { description: 'Task 2', prompt: 'echo world' } },858]),859toolResult(task1Id, 'Task 1 done'),860toolResult(task2Id, 'Task 2 done'),861assistantMsg([{ type: 'text', text: 'Both done!' }]),862], [subagent1, subagent2]));863864const response = result[1] as vscode.ChatResponseTurn2;865const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);866867// 2 Task tools + 2 subagent Bash tools868expect(toolParts).toHaveLength(4);869870// First two are the Task tools871expect(toolParts[0].toolName).toBe('Task');872expect(toolParts[1].toolName).toBe('Task');873874// Subagent tools follow their respective Task results875const subagent1Tools = toolParts.filter(t => t.subAgentInvocationId === task1Id);876expect(subagent1Tools).toHaveLength(1);877expect(subagent1Tools[0].toolName).toBe('Bash');878879const subagent2Tools = toolParts.filter(t => t.subAgentInvocationId === task2Id);880expect(subagent2Tools).toHaveLength(1);881expect(subagent2Tools[0].toolName).toBe('Bash');882});883884it('correctly associates subagents when Task results are interleaved with non-Task results', () => {885const taskId = 'toolu_task_interleave';886const bashId = 'toolu_bash_main';887888const subagent = subagentSession('agent-interleave', [889assistantMsg([{ type: 'tool_use', id: 'toolu_sub_glob', name: 'Glob', input: { pattern: '*.ts' } }]),890toolResult('toolu_sub_glob', 'found files'),891], taskId);892893const result = buildChatHistory(session([894userMsg('do stuff'),895assistantMsg([896{ type: 'tool_use', id: bashId, name: 'Bash', input: { command: 'echo hi' } },897{ type: 'tool_use', id: taskId, name: 'Task', input: { description: 'Sub task', prompt: 'find files' } },898]),899// Non-Task tool result first, then Task result — separate StoredMessages900toolResult(bashId, 'hi'),901toolResult(taskId, 'Sub task done'),902assistantMsg([{ type: 'text', text: 'All done.' }]),903], [subagent]));904905const response = result[1] as vscode.ChatResponseTurn2;906const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);907908// Bash (main) + Task + subagent Glob = 3 tools909expect(toolParts).toHaveLength(3);910expect(toolParts[0].toolName).toBe('Bash');911expect(toolParts[0].subAgentInvocationId).toBeUndefined();912913expect(toolParts[1].toolName).toBe('Task');914expect(toolParts[1].subAgentInvocationId).toBeUndefined();915916// Subagent tool is correctly linked to the Task, not the Bash tool917const subagentTools = toolParts.filter(t => t.subAgentInvocationId === taskId);918expect(subagentTools).toHaveLength(1);919expect(subagentTools[0].toolName).toBe('Glob');920});921922it('handles mixed Agent and Task tool names in same session', () => {923const taskId = 'toolu_task_old';924const agentId = 'toolu_agent_new';925926const subagent1 = subagentSession('old-agent', [927assistantMsg([{ type: 'tool_use', id: 'toolu_bash_old', name: 'Bash', input: { command: 'echo old' } }]),928toolResult('toolu_bash_old', 'old'),929], taskId);930931const subagent2 = subagentSession('new-agent', [932assistantMsg([{ type: 'tool_use', id: 'toolu_bash_new', name: 'Bash', input: { command: 'echo new' } }]),933toolResult('toolu_bash_new', 'new'),934], agentId);935936const result = buildChatHistory(session([937userMsg('do stuff'),938assistantMsg([939{ type: 'tool_use', id: taskId, name: 'Task', input: { description: 'Old task', prompt: 'old' } },940{ type: 'tool_use', id: agentId, name: 'Agent', input: { description: 'New agent', prompt: 'new' } },941]),942toolResult(taskId, 'Old done'),943toolResult(agentId, 'New done'),944assistantMsg([{ type: 'text', text: 'Both done.' }]),945], [subagent1, subagent2]));946947const response = result[1] as vscode.ChatResponseTurn2;948const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);949950// Task + its subagent Bash + Agent + its subagent Bash = 4951expect(toolParts).toHaveLength(4);952expect(toolParts[0].toolName).toBe('Task');953expect(toolParts[1].toolName).toBe('Agent');954expect(toolParts.filter(t => t.subAgentInvocationId === taskId)).toHaveLength(1);955expect(toolParts.filter(t => t.subAgentInvocationId === agentId)).toHaveLength(1);956});957958it('excludes subagents without parentToolUseId from injection', () => {959const taskToolUseId = 'toolu_task_orphan';960961const orphanSubagent = subagentSession('orphan-agent', [962assistantMsg([{ type: 'tool_use', id: 'toolu_bash_orphan', name: 'Bash', input: { command: 'echo orphan' } }]),963toolResult('toolu_bash_orphan', 'orphan output'),964]);965966const result = buildChatHistory(session([967userMsg('run a task'),968assistantMsg([{ type: 'tool_use', id: taskToolUseId, name: 'Agent', input: { description: 'Do work', prompt: 'work' } }]),969toolResult(taskToolUseId, 'Done'),970assistantMsg([{ type: 'text', text: 'Finished.' }]),971], [orphanSubagent]));972973const response = result[1] as vscode.ChatResponseTurn2;974const toolParts = response.response.filter((p): p is vscode.ChatToolInvocationPart => p instanceof ChatToolInvocationPart);975976// Only the Agent tool itself, no subagent tools injected977expect(toolParts).toHaveLength(1);978expect(toolParts[0].toolName).toBe('Agent');979expect(toolParts[0].subAgentInvocationId).toBeUndefined();980});981});982983// #endregion984985// #region Image References986987describe('image references', () => {988it('creates request turn with image references from base64 image blocks', () => {989const result = buildChatHistory(session([990userMsg([991{992type: 'image',993source: {994type: 'base64',995media_type: 'image/png',996data: 'iVBORw0KGgo=',997},998} as Anthropic.ImageBlockParam,999{ type: 'text', text: 'What is this?' },1000]),1001assistantMsg([{ type: 'text', text: 'An image.' }]),1002]));10031004expect(result).toHaveLength(2);1005const requestTurn = result[0] as vscode.ChatRequestTurn2;1006expect(requestTurn.prompt).toBe('What is this?');1007expect(requestTurn.references).toHaveLength(1);10081009const ref = requestTurn.references[0];1010expect(ref.value).toBeInstanceOf(ChatReferenceBinaryData);1011const binaryData = ref.value as InstanceType<typeof ChatReferenceBinaryData>;1012expect(binaryData.mimeType).toBe('image/png');1013});10141015it('reconstructs binary data from base64 in image references', async () => {1016const result = buildChatHistory(session([1017userMsg([1018{1019type: 'image',1020source: {1021type: 'base64',1022media_type: 'image/jpeg',1023data: Buffer.from([0xFF, 0xD8]).toString('base64'),1024},1025} as Anthropic.ImageBlockParam,1026{ type: 'text', text: 'Describe' },1027]),1028]));10291030const requestTurn = result[0] as vscode.ChatRequestTurn2;1031const binaryData = requestTurn.references[0].value as InstanceType<typeof ChatReferenceBinaryData>;1032const data = await binaryData.data();1033expect(Buffer.from(data)).toEqual(Buffer.from([0xFF, 0xD8]));1034});10351036it('creates request turn with multiple image references', () => {1037const result = buildChatHistory(session([1038userMsg([1039{1040type: 'image',1041source: { type: 'base64', media_type: 'image/png', data: 'aQ==' },1042} as Anthropic.ImageBlockParam,1043{1044type: 'image',1045source: { type: 'base64', media_type: 'image/jpeg', data: 'bQ==' },1046} as Anthropic.ImageBlockParam,1047{ type: 'text', text: 'Compare these' },1048]),1049]));10501051const requestTurn = result[0] as vscode.ChatRequestTurn2;1052expect(requestTurn.references).toHaveLength(2);1053expect((requestTurn.references[0].value as InstanceType<typeof ChatReferenceBinaryData>).mimeType).toBe('image/png');1054expect((requestTurn.references[1].value as InstanceType<typeof ChatReferenceBinaryData>).mimeType).toBe('image/jpeg');1055});10561057it('creates request turn for image-only messages with no text', () => {1058const result = buildChatHistory(session([1059userMsg([1060{1061type: 'image',1062source: { type: 'base64', media_type: 'image/png', data: 'aQ==' },1063} as Anthropic.ImageBlockParam,1064]),1065]));10661067// Even with no text, should produce a request turn because of the image1068expect(result).toHaveLength(1);1069const requestTurn = result[0] as vscode.ChatRequestTurn2;1070expect(requestTurn.references).toHaveLength(1);1071});10721073it('creates URI reference for URL-based image blocks', () => {1074const result = buildChatHistory(session([1075userMsg([1076{1077type: 'image',1078source: { type: 'url', url: 'https://example.com/img.png' },1079} as Anthropic.ImageBlockParam,1080{ type: 'text', text: 'What is this?' },1081]),1082]));10831084const requestTurn = result[0] as vscode.ChatRequestTurn2;1085expect(requestTurn.references).toHaveLength(1);1086const ref = requestTurn.references[0];1087expect(URI.isUri(ref.value)).toBe(true);1088expect((ref.value as URI).toString()).toBe('https://example.com/img.png');1089});1090});10911092// #endregion10931094// #region Slash Command Messages10951096describe('slash command messages', () => {1097it('renders /compact command as request turn with stdout as response turn', () => {1098const result = buildChatHistory(session([1099userMsg('Hello'),1100assistantMsg([{ type: 'text', text: 'Hi there' }]),1101// Command message with <command-name> tags1102userMsg([1103{ type: 'text', text: '<system-reminder>\nContext.\n</system-reminder>' },1104{ type: 'text', text: '<command-name>/compact</command-name>\n <command-message>compact</command-message>\n <command-args></command-args>' },1105]),1106// Command stdout in a separate user message1107userMsg('<local-command-stdout>Compacted PreCompact [callback] completed successfully</local-command-stdout>'),1108]));11091110const snapshot = mapHistoryForSnapshot(result);1111// Request, Response, Command Request, Command Response1112expect(snapshot).toHaveLength(4);1113expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Hello' });1114expect(snapshot[1]).toMatchObject({ type: 'response' });1115expect(snapshot[2]).toMatchObject({ type: 'request', prompt: '/compact' });1116expect(snapshot[3]).toMatchObject({1117type: 'response',1118parts: [{ type: 'markdown', content: 'Compacted PreCompact [callback] completed successfully' }],1119});1120});11211122it('renders /init command as request turn without stdout', () => {1123const result = buildChatHistory(session([1124// Init command message (string format from real fixture)1125userMsg('<command-message>init is analyzing your codebase…</command-message>\n<command-name>/init</command-name>'),1126assistantMsg([{ type: 'text', text: 'Analyzing...' }]),1127]));11281129const snapshot = mapHistoryForSnapshot(result);1130expect(snapshot).toHaveLength(2);1131expect(snapshot[0]).toMatchObject({ type: 'request', prompt: '/init' });1132expect(snapshot[1]).toMatchObject({ type: 'response' });1133});11341135it('finalizes pending response before command request turn', () => {1136const result = buildChatHistory(session([1137userMsg('Do task'),1138assistantMsg([1139{ type: 'text', text: 'Working...' },1140{ type: 'tool_use', id: 't1', name: 'bash', input: { command: 'echo done' } },1141]),1142toolResult('t1', 'done'),1143assistantMsg([{ type: 'text', text: 'Finished.' }]),1144// Now the user runs /compact1145userMsg([1146{ type: 'text', text: '<command-name>/compact</command-name>\n<command-message>compact</command-message>\n<command-args></command-args>' },1147]),1148userMsg('<local-command-stdout>Compacted successfully</local-command-stdout>'),1149]));11501151const snapshot = mapHistoryForSnapshot(result);1152// Request, Response (with tool + text), Command Request, Command Response1153expect(snapshot).toHaveLength(4);1154expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Do task' });1155expect(snapshot[1]).toMatchObject({ type: 'response' });1156expect(snapshot[2]).toMatchObject({ type: 'request', prompt: '/compact' });1157expect(snapshot[3]).toMatchObject({1158type: 'response',1159parts: [{ type: 'markdown', content: 'Compacted successfully' }],1160});1161});11621163it('handles command without stdout (no response turn emitted)', () => {1164const result = buildChatHistory(session([1165userMsg([1166{ type: 'text', text: '<command-name>/help</command-name>\n<command-message>help</command-message>\n<command-args></command-args>' },1167]),1168]));11691170const snapshot = mapHistoryForSnapshot(result);1171// Only the command request turn, no response1172expect(snapshot).toHaveLength(1);1173expect(snapshot[0]).toMatchObject({ type: 'request', prompt: '/help' });1174});11751176it('renders full compact sequence: system message, command, and stdout', () => {1177const systemMessage: StoredMessage = {1178uuid: 'sys-1',1179sessionId: 'test-session',1180timestamp: new Date(),1181parentUuid: null,1182type: 'system',1183message: { role: 'system' as const, content: 'Conversation compacted' },1184};11851186const result = buildChatHistory(session([1187userMsg('Hello'),1188assistantMsg([{ type: 'text', text: 'Hi there' }]),1189// System compact_boundary1190systemMessage,1191// /compact command1192userMsg([1193{ type: 'text', text: '<system-reminder>\nContext.\n</system-reminder>' },1194{ type: 'text', text: '<command-name>/compact</command-name>\n<command-message>compact</command-message>\n<command-args></command-args>' },1195]),1196// Stdout1197userMsg('<local-command-stdout>Compacted successfully</local-command-stdout>'),1198// In real sessions, a synthetic assistant message separates the command from the next turn1199assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),1200// Conversation continues1201userMsg('What were we talking about?'),1202assistantMsg([{ type: 'text', text: 'We were discussing...' }]),1203]));12041205const snapshot = mapHistoryForSnapshot(result);1206// Request, Response (with system appended), Command Request, Command Response, Request, Response1207expect(snapshot).toHaveLength(6);1208expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Hello' });1209expect(snapshot[1]).toMatchObject({ type: 'response' });1210expect(snapshot[2]).toMatchObject({ type: 'request', prompt: '/compact' });1211expect(snapshot[3]).toMatchObject({ type: 'response', parts: [{ type: 'markdown', content: 'Compacted successfully' }] });1212expect(snapshot[4]).toMatchObject({ type: 'request', prompt: 'What were we talking about?' });1213expect(snapshot[5]).toMatchObject({ type: 'response' });12141215// The first response should have the assistant text + system separator1216const responseParts = getResponseParts(snapshot, 1);1217expect(responseParts).toHaveLength(2);1218expect(responseParts[0]).toMatchObject({ type: 'markdown', content: 'Hi there' });1219expect(responseParts[1]).toMatchObject({ type: 'markdown', content: '\n\n---\n\n*Conversation compacted*' });1220});1221});12221223// #endregion12241225// #region Synthetic Message Filtering12261227describe('Synthetic Message Filtering', () => {1228it('filters out synthetic assistant messages', () => {1229const s = session([1230userMsg('Hello'),1231assistantMsg([{ type: 'text', text: 'Hi there!' }]),1232userMsg('Do something'),1233assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),1234]);12351236const result = buildChatHistory(s);1237const snapshot = mapHistoryForSnapshot(result);12381239// The synthetic message should be filtered out entirely1240expect(snapshot).toEqual([1241{ type: 'request', prompt: 'Hello' },1242{ type: 'response', parts: [{ type: 'markdown', content: 'Hi there!' }] },1243{ type: 'request', prompt: 'Do something' },1244// No response from the synthetic message1245]);1246});12471248it('preserves non-synthetic assistant messages around synthetic ones', () => {1249const s = session([1250userMsg('Hello'),1251assistantMsg([{ type: 'text', text: 'Real response' }]),1252assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),1253]);12541255const result = buildChatHistory(s);1256const snapshot = mapHistoryForSnapshot(result);12571258expect(snapshot).toEqual([1259{ type: 'request', prompt: 'Hello' },1260{ type: 'response', parts: [{ type: 'markdown', content: 'Real response' }] },1261]);1262});12631264it('filters synthetic messages in the middle of a tool loop', () => {1265const result = buildChatHistory(session([1266userMsg('Do task'),1267assistantMsg([1268{ type: 'text', text: 'Working...' },1269{ type: 'tool_use', id: 't1', name: 'bash', input: { command: 'echo hi' } },1270]),1271toolResult('t1', 'hi'),1272// Synthetic message mid-loop (e.g., from an abort)1273assistantMsg([{ type: 'text', text: 'No response requested.' }], '<synthetic>'),1274// Real assistant continues1275assistantMsg([{ type: 'text', text: 'Done.' }]),1276]));12771278const snapshot = mapHistoryForSnapshot(result);1279expect(snapshot).toHaveLength(2);1280expect(snapshot[0]).toMatchObject({ type: 'request', prompt: 'Do task' });12811282// The response should contain the tool call, the text before, and the text after — but not the synthetic message1283const parts = getResponseParts(snapshot, 1);1284const markdownParts = parts.filter(p => p.type === 'markdown');1285expect(markdownParts).toEqual([1286{ type: 'markdown', content: 'Working...' },1287{ type: 'markdown', content: 'Done.' },1288]);1289// No "No response requested." in any part1290expect(parts.every(p => p.type !== 'markdown' || (p as Record<string, unknown>).content !== 'No response requested.')).toBe(true);1291});1292});12931294// #endregion12951296// #region Model ID Resolution12971298describe('model ID resolution via parseClaudeModelId', () => {1299it('converts SDK model ID to endpoint format on request turns', () => {1300const s = session([1301userMsg('Hello'),1302assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),1303]);13041305const result = buildChatHistory(s);13061307const requestTurn = result[0] as vscode.ChatRequestTurn2;1308expect(requestTurn).toBeInstanceOf(ChatRequestTurn2);1309expect(requestTurn.modelId).toBe('claude-opus-4.5');1310});13111312it('falls back to raw model ID when parsing fails', () => {1313const s = session([1314userMsg('Hello'),1315assistantMsg([{ type: 'text', text: 'Hi' }], 'unknown-model-id'),1316]);13171318const result = buildChatHistory(s);13191320const requestTurn = result[0] as vscode.ChatRequestTurn2;1321expect(requestTurn.modelId).toBe('unknown-model-id');1322});13231324it('skips synthetic assistant messages when resolving model ID', () => {1325const s = session([1326userMsg('Hello'),1327assistantMsg([{ type: 'text', text: 'No response requested.' }], SYNTHETIC_MODEL_ID),1328assistantMsg([{ type: 'text', text: 'Real response' }], 'claude-sonnet-4-20250514'),1329]);13301331const result = buildChatHistory(s);13321333const requestTurn = result[0] as vscode.ChatRequestTurn2;1334expect(requestTurn.modelId).toBe('claude-sonnet-4');1335});13361337it('returns undefined modelId when no assistant message follows', () => {1338const s = session([1339userMsg('Hello'),1340]);13411342const result = buildChatHistory(s);13431344const requestTurn = result[0] as vscode.ChatRequestTurn2;1345expect(requestTurn.modelId).toBeUndefined();1346});13471348it('uses the correct model for each request in multi-turn conversations', () => {1349const s = session([1350userMsg('First question'),1351assistantMsg([{ type: 'text', text: 'First answer' }], 'claude-sonnet-4-20250514'),1352userMsg('Second question'),1353assistantMsg([{ type: 'text', text: 'Second answer' }], 'claude-opus-4-5-20251101'),1354]);13551356const result = buildChatHistory(s);13571358const firstRequest = result[0] as vscode.ChatRequestTurn2;1359expect(firstRequest.modelId).toBe('claude-sonnet-4');13601361const secondRequest = result[2] as vscode.ChatRequestTurn2;1362expect(secondRequest.modelId).toBe('claude-opus-4.5');1363});13641365it('tags command request turns with converted model ID', () => {1366const s = session([1367userMsg('<command-name>/compact</command-name><command-message>compact</command-message>'),1368assistantMsg([{ type: 'text', text: 'Compacted.' }], 'claude-sonnet-4-20250514'),1369]);13701371const result = buildChatHistory(s);13721373const commandTurn = result[0] as vscode.ChatRequestTurn2;1374expect(commandTurn.prompt).toBe('/compact');1375expect(commandTurn.modelId).toBe('claude-sonnet-4');1376});13771378it('preserves endpoint-format model IDs as-is', () => {1379const s = session([1380userMsg('Hello'),1381assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4.5'),1382]);13831384const result = buildChatHistory(s);13851386const requestTurn = result[0] as vscode.ChatRequestTurn2;1387expect(requestTurn.modelId).toBe('claude-opus-4.5');1388});1389});13901391// #endregion13921393// #region Response Details (model footer)13941395describe('response details via getModelDetails', () => {1396// Returns the raw model id back so we can spot-check exactly which id the1397// builder fed into the lookup for each response turn.1398const echoLookup = (id: string) => `details:${id}`;13991400it('omits details when no lookup is provided (regression)', () => {1401const s = session([1402userMsg('Hello'),1403assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),1404]);14051406const result = buildChatHistory(s);14071408const responseTurn = result[1] as vscode.ChatResponseTurn2;1409expect(responseTurn.result).toEqual({});1410});14111412it('attaches details from the assistant model id to the response turn', () => {1413const s = session([1414userMsg('Hello'),1415assistantMsg([{ type: 'text', text: 'Hi' }], 'claude-opus-4-5-20251101'),1416]);14171418const result = buildChatHistory(s, echoLookup);14191420const responseTurn = result[1] as vscode.ChatResponseTurn2;1421expect(responseTurn.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });1422});14231424it('omits details when the lookup returns undefined', () => {1425const s = session([1426userMsg('Hello'),1427assistantMsg([{ type: 'text', text: 'Hi' }], 'unknown-model-id'),1428]);14291430const result = buildChatHistory(s, () => undefined);14311432const responseTurn = result[1] as vscode.ChatResponseTurn2;1433expect(responseTurn.result).toEqual({});1434});14351436it('attributes per-response model details across model switches', () => {1437const s = session([1438userMsg('First'),1439assistantMsg([{ type: 'text', text: 'A1' }], 'claude-sonnet-4-20250514'),1440userMsg('Second'),1441assistantMsg([{ type: 'text', text: 'A2' }], 'claude-opus-4-5-20251101'),1442]);14431444const result = buildChatHistory(s, echoLookup);14451446const firstResponse = result[1] as vscode.ChatResponseTurn2;1447const secondResponse = result[3] as vscode.ChatResponseTurn2;1448expect(firstResponse.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });1449expect(secondResponse.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });1450});14511452it('uses the last non-synthetic assistant model in a multi-message response group', () => {1453const s = session([1454userMsg('Run'),1455assistantMsg([{ type: 'tool_use', id: 't1', name: 'bash', input: {} }], 'claude-sonnet-4-20250514'),1456toolResult('t1', 'done'),1457// Final assistant message uses a different model — that's the one we attribute.1458assistantMsg([{ type: 'text', text: 'OK' }], 'claude-opus-4-5-20251101'),1459]);14601461const result = buildChatHistory(s, echoLookup);14621463const responseTurn = result[1] as vscode.ChatResponseTurn2;1464expect(responseTurn.result).toEqual({ details: 'details:claude-opus-4-5-20251101' });1465});14661467it('does not bleed model ids across response groups when lookup is undefined for one', () => {1468const s = session([1469userMsg('First'),1470assistantMsg([{ type: 'text', text: 'A1' }], 'claude-sonnet-4-20250514'),1471userMsg('Second'),1472assistantMsg([{ type: 'text', text: 'A2' }], 'unknown-model-id'),1473]);14741475const result = buildChatHistory(s, id => id === 'claude-sonnet-4-20250514' ? 'Sonnet' : undefined);14761477const firstResponse = result[1] as vscode.ChatResponseTurn2;1478const secondResponse = result[3] as vscode.ChatResponseTurn2;1479expect(firstResponse.result).toEqual({ details: 'Sonnet' });1480expect(secondResponse.result).toEqual({});1481});14821483it('ignores synthetic assistant messages when picking the response model id', () => {1484const s = session([1485userMsg('Hello'),1486assistantMsg([{ type: 'text', text: 'Real reply' }], 'claude-sonnet-4-20250514'),1487// A trailing synthetic message (e.g. cancellation marker) must not1488// override the real model id we just observed.1489assistantMsg([{ type: 'text', text: 'No response requested.' }], SYNTHETIC_MODEL_ID),1490]);14911492const result = buildChatHistory(s, echoLookup);14931494const responseTurn = result[1] as vscode.ChatResponseTurn2;1495expect(responseTurn.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });1496});14971498it('attaches details to slash-command response turns', () => {1499const s = session([1500userMsg('<command-name>/compact</command-name><command-message>compact</command-message>'),1501assistantMsg([{ type: 'text', text: 'Compacted.' }], 'claude-sonnet-4-20250514'),1502]);15031504const result = buildChatHistory(s, echoLookup);15051506// [request, response]1507const responseTurn = result[1] as vscode.ChatResponseTurn2;1508expect(responseTurn.result).toEqual({ details: 'details:claude-sonnet-4-20250514' });1509});1510});15111512// #endregion1513});151415151516