Path: blob/main/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts
13399 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 assert from 'assert';6import { URI } from '../../../../base/common/uri.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';8import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolInputString, getToolKind, isHiddenTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js';910suite('getPermissionDisplay — cd-prefix stripping', () => {1112ensureNoDisposablesAreLeakedInTestSuite();1314const wd = URI.file('/repo/project');1516test('strips redundant cd from shell permission request fullCommandText', () => {17const request: ITypedPermissionRequest = {18kind: 'shell',19fullCommandText: 'cd /repo/project && npm test',20} as ITypedPermissionRequest;21const display = getPermissionDisplay(request, wd);22assert.strictEqual(display.toolInput, 'npm test');23assert.strictEqual(display.permissionKind, 'shell');24});2526test('leaves shell command alone when cd target differs from working directory', () => {27const request: ITypedPermissionRequest = {28kind: 'shell',29fullCommandText: 'cd /tmp && ls',30} as ITypedPermissionRequest;31const display = getPermissionDisplay(request, wd);32assert.strictEqual(display.toolInput, 'cd /tmp && ls');33});3435test('leaves shell command alone when no working directory provided', () => {36const request: ITypedPermissionRequest = {37kind: 'shell',38fullCommandText: 'cd /repo/project && npm test',39} as ITypedPermissionRequest;40const display = getPermissionDisplay(request, undefined);41assert.strictEqual(display.toolInput, 'cd /repo/project && npm test');42});4344test('strips redundant cd from custom-tool shell permission request', () => {45const request: ITypedPermissionRequest = {46kind: 'custom-tool',47toolName: 'bash',48args: { command: 'cd /repo/project && echo hi' },49} as ITypedPermissionRequest;50const display = getPermissionDisplay(request, wd);51assert.strictEqual(display.toolInput, 'echo hi');52assert.strictEqual(display.permissionKind, 'shell');53});5455test('does not affect non-shell custom-tool requests', () => {56const request: ITypedPermissionRequest = {57kind: 'custom-tool',58toolName: 'some_other_tool',59args: { command: 'cd /repo/project && echo hi' },60} as ITypedPermissionRequest;61const display = getPermissionDisplay(request, wd);62// Falls through to the generic branch — toolInput is the JSON-stringified args.63assert.ok(display.toolInput?.includes('cd /repo/project'), `expected unrewritten args, got: ${display.toolInput}`);64assert.strictEqual(display.permissionKind, 'custom-tool');65});6667test('handles powershell custom-tool with semicolon separator', () => {68const request: ITypedPermissionRequest = {69kind: 'custom-tool',70toolName: 'powershell',71args: { command: 'cd /repo/project; dir' },72} as ITypedPermissionRequest;73const display = getPermissionDisplay(request, wd);74assert.strictEqual(display.toolInput, 'dir');75});76});7778suite('view tool — view_range display', () => {7980ensureNoDisposablesAreLeakedInTestSuite();8182function invocation(parameters: Record<string, unknown> | undefined): string {83const result = getInvocationMessage('view', 'View File', parameters);84return typeof result === 'string' ? result : result.markdown;85}8687function pastTense(parameters: Record<string, unknown> | undefined): string {88const result = getPastTenseMessage('view', 'View File', parameters, true);89return typeof result === 'string' ? result : result.markdown;90}9192test('renders path-only when view_range is absent', () => {93assert.ok(invocation({ path: '/repo/file.ts' }).startsWith('Reading ['));94assert.ok(pastTense({ path: '/repo/file.ts' }).startsWith('Read ['));95});9697test('renders "lines X to Y" for a valid two-element range', () => {98assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20'));99assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20'));100});101102test('renders "line X" when start === end', () => {103assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10'));104assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10'));105});106107test('renders "line X to the end" for the -1 EOF sentinel', () => {108assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end'));109assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end'));110});111112test('falls back to path-only for invalid ranges', () => {113// end < start (and not -1)114assert.ok(!invocation({ path: '/repo/file.ts', view_range: [20, 10] }).includes(','));115// negative start116assert.ok(!invocation({ path: '/repo/file.ts', view_range: [-5, 10] }).includes(','));117// non-integer118assert.ok(!invocation({ path: '/repo/file.ts', view_range: [1.5, 10] }).includes(','));119// wrong arity120assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10] }).includes(','));121assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10, 20, 30] }).includes(','));122// non-array123assert.ok(!invocation({ path: '/repo/file.ts', view_range: 'whatever' }).includes(','));124});125});126127// ---- write_/read_ shell tool display ---------------------------------------128//129// Coverage for the secondary shell helpers (write_bash, read_bash, and their130// powershell siblings). These never appear in a permission dialog (they're131// registered with `skipPermission: true` — see copilotShellTools.ts), but they132// still flow through the tool-execution display pipeline.133134suite('copilotToolDisplay — write_/read_ shell tools', () => {135136ensureNoDisposablesAreLeakedInTestSuite();137138suite('getToolKind', () => {139140test('returns terminal for bash', () => {141assert.strictEqual(getToolKind('bash'), 'terminal');142});143144test('returns terminal for powershell', () => {145assert.strictEqual(getToolKind('powershell'), 'terminal');146});147148test('returns undefined for write_bash (sending input to a running program, not launching a terminal)', () => {149assert.strictEqual(getToolKind('write_bash'), undefined);150});151152test('returns undefined for write_powershell', () => {153assert.strictEqual(getToolKind('write_powershell'), undefined);154});155156test('returns undefined for read_bash (reading output, not launching a terminal)', () => {157assert.strictEqual(getToolKind('read_bash'), undefined);158});159160test('returns undefined for read_powershell', () => {161assert.strictEqual(getToolKind('read_powershell'), undefined);162});163164test('returns subagent for task', () => {165assert.strictEqual(getToolKind('task'), 'subagent');166});167168test('returns undefined for view', () => {169assert.strictEqual(getToolKind('view'), undefined);170});171});172173suite('getShellLanguage', () => {174175test('bash returns shellscript', () => {176assert.strictEqual(getShellLanguage('bash'), 'shellscript');177});178179test('powershell returns powershell', () => {180assert.strictEqual(getShellLanguage('powershell'), 'powershell');181});182183test('write_bash returns shellscript', () => {184assert.strictEqual(getShellLanguage('write_bash'), 'shellscript');185});186187test('write_powershell returns powershell', () => {188assert.strictEqual(getShellLanguage('write_powershell'), 'powershell');189});190191test('read_bash returns shellscript', () => {192assert.strictEqual(getShellLanguage('read_bash'), 'shellscript');193});194195test('read_powershell returns powershell', () => {196assert.strictEqual(getShellLanguage('read_powershell'), 'powershell');197});198});199200suite('getInvocationMessage', () => {201202function getText(msg: ReturnType<typeof getInvocationMessage>): string {203return typeof msg === 'string' ? msg : msg.markdown;204}205206test('write_bash with command includes the command text', () => {207const msg = getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hello' });208assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`);209});210211test('write_bash without command returns a non-empty fallback message', () => {212const msg = getInvocationMessage('write_bash', 'Write Shell Input', undefined);213assert.ok(getText(msg).length > 0);214assert.ok(!getText(msg).includes('undefined'));215});216217test('write_powershell with command includes the command text', () => {218const msg = getInvocationMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' });219assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`);220});221222test('read_bash returns a non-empty message', () => {223const msg = getInvocationMessage('read_bash', 'Read Shell Output', undefined);224assert.ok(getText(msg).length > 0);225});226227test('read_powershell returns a non-empty message', () => {228const msg = getInvocationMessage('read_powershell', 'Read Shell Output', undefined);229assert.ok(getText(msg).length > 0);230});231232test('write_bash message differs from bash message (distinct wording)', () => {233const writeBashMsg = getText(getInvocationMessage('write_bash', 'Write Shell Input', { command: 'echo hi' }));234const bashMsg = getText(getInvocationMessage('bash', 'Bash', { command: 'echo hi' }));235// Both include the command, but the surrounding text should differ236assert.notStrictEqual(writeBashMsg, bashMsg);237});238});239240suite('getPastTenseMessage', () => {241242function getText(msg: ReturnType<typeof getPastTenseMessage>): string {243return typeof msg === 'string' ? msg : msg.markdown;244}245246test('write_bash with command includes the command text', () => {247const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, true);248assert.ok(getText(msg).includes('echo hello'), `expected 'echo hello' in: ${getText(msg)}`);249});250251test('write_bash without command returns a non-empty fallback message', () => {252const msg = getPastTenseMessage('write_bash', 'Write Shell Input', undefined, true);253assert.ok(getText(msg).length > 0);254});255256test('write_powershell with command includes the command text', () => {257const msg = getPastTenseMessage('write_powershell', 'Write Shell Input', { command: 'Get-Date' }, true);258assert.ok(getText(msg).includes('Get-Date'), `expected 'Get-Date' in: ${getText(msg)}`);259});260261test('read_bash success returns a non-empty message', () => {262const msg = getPastTenseMessage('read_bash', 'Read Shell Output', undefined, true);263assert.ok(getText(msg).length > 0);264});265266test('write_bash failure returns a non-empty error message', () => {267const msg = getPastTenseMessage('write_bash', 'Write Shell Input', { command: 'echo hello' }, false);268assert.ok(getText(msg).length > 0);269});270});271272suite('getToolInputString', () => {273274test('write_bash extracts command field', () => {275assert.strictEqual(getToolInputString('write_bash', { command: 'echo hello' }, undefined), 'echo hello');276});277278test('write_powershell extracts command field', () => {279assert.strictEqual(getToolInputString('write_powershell', { command: 'Get-Date' }, undefined), 'Get-Date');280});281282test('write_bash falls back to rawArguments when no command field', () => {283assert.strictEqual(getToolInputString('write_bash', {}, '{"command":"echo hello"}'), '{"command":"echo hello"}');284});285286test('write_bash returns undefined when both parameters and rawArguments are absent', () => {287assert.strictEqual(getToolInputString('write_bash', undefined, undefined), undefined);288});289290test('read_bash with no parameters returns undefined', () => {291assert.strictEqual(getToolInputString('read_bash', undefined, undefined), undefined);292});293});294});295296suite('skill events', () => {297298ensureNoDisposablesAreLeakedInTestSuite();299300test('hides the raw `skill` tool call and synthesizes a tool-start/complete pair from `skill.invoked`', () => {301const withPath = synthesizeSkillToolCall(302{ name: 'plan', path: '/abs/repo/skills/plan/SKILL.md' },303'evt-123',304);305const noPath = synthesizeSkillToolCall(306{ name: 'plan' },307undefined,308);309310assert.deepStrictEqual({311skillIsHidden: isHiddenTool('skill'),312withPathToolCallId: withPath.toolCallId,313withPathToolName: withPath.toolName,314withPathDisplayName: withPath.displayName,315withPathInvocation: withPath.invocationMessage,316withPathPastTense: withPath.pastTenseMessage,317noPathToolCallId: noPath.toolCallId,318noPathInvocation: noPath.invocationMessage,319noPathPastTense: noPath.pastTenseMessage,320}, {321skillIsHidden: true,322withPathToolCallId: 'synth-skill-evt-123',323withPathToolName: 'skill',324withPathDisplayName: 'Read Skill',325withPathInvocation: { markdown: 'Reading skill [plan](file:///abs/repo/skills/plan/SKILL.md)' },326withPathPastTense: { markdown: 'Read skill [plan](file:///abs/repo/skills/plan/SKILL.md)' },327noPathToolCallId: 'synth-skill-2108d652',328noPathInvocation: 'Reading skill plan',329noPathPastTense: 'Read skill plan',330});331});332});333334335