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