Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/permissionHelpers.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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';6import type { CancellationToken, ChatParticipantToolToken } from 'vscode';7import { ILogService } from '../../../../../platform/log/common/logService';8import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';9import { CancellationTokenSource } from '../../../../../util/vs/base/common/cancellation';10import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';11import { URI } from '../../../../../util/vs/base/common/uri';12import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';13import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../../vscodeTypes';14import { createExtensionUnitTestingServices } from '../../../../test/node/services';15import { ToolName } from '../../../../tools/common/toolNames';16import { IToolsService } from '../../../../tools/common/toolsService';17import { ExternalEditTracker } from '../../../common/externalEditTracker';18import { IWorkspaceInfo } from '../../../common/workspaceInfo';19import { ICopilotCLIImageSupport } from '../copilotCLIImageSupport';20import { buildMcpConfirmationParams, buildShellConfirmationParams, handleReadPermission, handleWritePermission, isFileFromSessionWorkspace, PermissionRequest, requiresFileEditconfirmation, showInteractivePermissionPrompt } from '../permissionHelpers';212223describe('CopilotCLI permissionHelpers', () => {24const disposables = new DisposableStore();25let instaService: IInstantiationService;26beforeEach(() => {27const services = disposables.add(createExtensionUnitTestingServices());28instaService = services.seal();29});3031afterEach(() => {32disposables.clear();33});3435describe('buildShellConfirmationParams', () => {36it('shell: uses intention over command text and sets terminal confirmation tool', () => {37const req = { kind: 'shell', intention: 'List workspace files', fullCommandText: 'ls -la' } as any;38const result = buildShellConfirmationParams(req, undefined);39expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool);40expect(result.input.message).toBe('List workspace files');41expect(result.input.command).toBe('ls -la');42expect(result.input.isBackground).toBe(false);43});4445it('shell: falls back to fullCommandText when no intention', () => {46const req = { kind: 'shell', fullCommandText: 'echo "hi"' } as any;47const result = buildShellConfirmationParams(req, undefined);48expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool);49expect(result.input.message).toBe('echo "hi"');50expect(result.input.command).toBe('echo "hi"');51});5253it('shell: falls back to codeBlock when neither intention nor command text provided', () => {54const req = { kind: 'shell' } as any;55const result = buildShellConfirmationParams(req, undefined);56expect(result.tool).toBe(ToolName.CoreTerminalConfirmationTool);57// codeBlock starts with two newlines then ```58expect(result.input.message).toMatch(/^\n\n```/);59});6061it('shell: strips cd prefix from command when matching workingDirectory on bash', () => {62const workingDirectory = URI.file('/workspace');63const req = { kind: 'shell', fullCommandText: `cd ${workingDirectory.fsPath} && npm test` } as any;64const result = buildShellConfirmationParams(req, workingDirectory, false);65expect(result.input.command).toBe('npm test');66expect(result.input.message).toBe('npm test');67});6869it('shell: keeps full command when cd prefix does not match workingDirectory on bash', () => {70const fullCommandText = `cd ${URI.file('/other').fsPath} && npm test`;71const req = { kind: 'shell', fullCommandText: fullCommandText } as any;72const workingDirectory = URI.file('/workspace');73const result = buildShellConfirmationParams(req, workingDirectory, false);74expect(result.input.command).toBe(fullCommandText);75expect(result.input.message).toBe(fullCommandText);76});7778it('shell: keeps full command with cd prefix when no workingDirectory', () => {79const fullCommandText = 'cd /workspace && npm test';80const req = { kind: 'shell', fullCommandText: fullCommandText } as any;81const result = buildShellConfirmationParams(req, undefined, false);82expect(result.input.command).toBe(fullCommandText);83expect(result.input.message).toBe(fullCommandText);84});8586it('shell: plain command without cd prefix is unchanged', () => {87const req = { kind: 'shell', fullCommandText: 'npm test' } as any;88const workingDirectory = URI.file('/workspace');89const result = buildShellConfirmationParams(req, workingDirectory, false);90expect(result.input.command).toBe('npm test');91expect(result.input.message).toBe('npm test');92});9394it('shell: intention takes priority in message even when cd prefix is stripped', () => {95const workingDirectory = URI.file('/workspace');96const fullCommandText = `cd ${workingDirectory.fsPath} && npm test`;97const req = { kind: 'shell', intention: 'Run unit tests', fullCommandText: fullCommandText } as any;98const result = buildShellConfirmationParams(req, workingDirectory, false);99expect(result.input.message).toBe('Run unit tests');100expect(result.input.command).toBe('npm test');101});102103it('shell: strips Set-Location prefix when matching workingDirectory on Windows', () => {104const workingDirectory = URI.file('C:\\workspace');105const fullCommandText = `Set-Location ${workingDirectory.fsPath}; npm test`;106const req = { kind: 'shell', fullCommandText: fullCommandText } as any;107const result = buildShellConfirmationParams(req, workingDirectory, true);108expect(result.input.command).toBe('npm test');109expect(result.input.message).toBe('npm test');110});111112it('shell: strips cd /d prefix when matching workingDirectory on Windows', () => {113const workingDirectory = URI.file('C:\\project');114const fullCommandText = `cd /d ${workingDirectory.fsPath} && npm start`;115const req = { kind: 'shell', fullCommandText } as any;116const result = buildShellConfirmationParams(req, workingDirectory, true);117expect(result.input.command).toBe('npm start');118expect(result.input.message).toBe('npm start');119});120121it('shell: strips Set-Location -Path prefix when matching workingDirectory on Windows', () => {122const workingDirectory = URI.file('C:\\project');123const fullCommandText = `Set-Location -Path ${workingDirectory.fsPath} && npm start`;124const req = { kind: 'shell', fullCommandText } as any;125const result = buildShellConfirmationParams(req, workingDirectory, true);126expect(result.input.command).toBe('npm start');127expect(result.input.message).toBe('npm start');128});129130it('shell: bash cd prefix not recognized when isWindows is true', () => {131// On Windows, isPowershell=true, so bash-style `cd /workspace &&` may not match the powershell regex132const workingDirectory = URI.file('/workspace');133const fullCommandText = `cd ${workingDirectory.fsPath} && npm test`;134const req = { kind: 'shell', fullCommandText } as any;135const result = buildShellConfirmationParams(req, workingDirectory, true);136// Powershell regex does match `cd <dir> &&` pattern (cd without /d), so stripping still happens137expect(result.input.command).toBe('npm test');138});139140it('shell: Windows Set-Location not recognized when isWindows is false', () => {141// On non-Windows, isPowershell=false, so Set-Location is not recognized142const workingDirectory = URI.file('C:\\workspace');143const fullCommandText = `Set-Location -Path ${workingDirectory.fsPath}; npm test`;144const req = { kind: 'shell', fullCommandText } as any;145const result = buildShellConfirmationParams(req, workingDirectory, false);146// Bash regex doesn't recognize Set-Location, so full command is kept147expect(result.input.command).toBe(fullCommandText);148});149});150151describe('buildMcpConfirmationParams', () => {152it('mcp: formats with serverName, toolTitle and args JSON', () => {153const req = { kind: 'mcp', serverName: 'files', toolTitle: 'List Files', toolName: 'list', args: { path: '/tmp' } } as any;154const result = buildMcpConfirmationParams(req as Extract<PermissionRequest, { kind: 'mcp' }>);155expect(result.tool).toBe(ToolName.CoreConfirmationTool);156expect(result.input.title).toBe('List Files');157expect(result.input.message).toContain('Server: files');158expect(result.input.message).toContain('"path": "/tmp"');159});160161it('mcp: falls back to generated title and full JSON when no serverName', () => {162const req = { kind: 'mcp', toolName: 'info', args: { detail: true } } as any;163const result = buildMcpConfirmationParams(req as Extract<PermissionRequest, { kind: 'mcp' }>);164expect(result.input.title).toBe('MCP Tool: info');165expect(result.input.message).toMatch(/```json/);166expect(result.input.message).toContain('"detail": true');167});168169it('mcp: uses Unknown when neither toolTitle nor toolName provided', () => {170const req = { kind: 'mcp', args: {} } as any;171const result = buildMcpConfirmationParams(req as Extract<PermissionRequest, { kind: 'mcp' }>);172expect(result.input.title).toBe('MCP Tool: Unknown');173});174});175176describe('requiresFileEditconfirmation', () => {177it('returns false for non-write requests', async () => {178const req = { kind: 'shell', fullCommandText: 'ls' } as any;179expect(await requiresFileEditconfirmation(instaService, req)).toBe(false);180});181182it('returns false when no fileName is provided', async () => {183const req = { kind: 'write', intention: 'edit' } as any;184expect(await requiresFileEditconfirmation(instaService, req)).toBe(false);185});186187it('requires confirmation for file outside workspace when no workingDirectory', async () => {188const req = { kind: 'write', fileName: URI.file('/some/path/foo.ts').fsPath, diff: '', intention: '' } as any;189expect(await requiresFileEditconfirmation(instaService, req)).toBe(true);190});191192it('does not require confirmation when workingDirectory covers the file', async () => {193const req = { kind: 'write', fileName: URI.file('/workspace/src/foo.ts').fsPath, diff: '', intention: '' } as any;194const workingDirectory = URI.file('/workspace');195expect(await requiresFileEditconfirmation(instaService, req, undefined, workingDirectory)).toBe(false);196});197198it('does not require confirmation when workingDirectory is provided', async () => {199const req = { kind: 'write', fileName: URI.file('/workspace/other/foo.ts').fsPath, diff: '', intention: '' } as any;200const workingDirectory = URI.file('/workspace');201// workingDirectory callback always returns the same folder, treating all files as in-workspace202expect(await requiresFileEditconfirmation(instaService, req, undefined, workingDirectory)).toBe(false);203});204});205206describe('isFileFromSessionWorkspace', () => {207it('returns true for file inside the working directory (folder)', () => {208const workspaceInfo: IWorkspaceInfo = {209folder: URI.file('/workspace'),210repository: undefined,211worktree: undefined,212worktreeProperties: undefined,213};214expect(isFileFromSessionWorkspace(URI.file('/workspace/src/foo.ts'), workspaceInfo)).toBe(true);215});216217it('returns false for file outside all known directories', () => {218const workspaceInfo: IWorkspaceInfo = {219folder: URI.file('/workspace'),220repository: undefined,221worktree: undefined,222worktreeProperties: undefined,223};224expect(isFileFromSessionWorkspace(URI.file('/other/path/foo.ts'), workspaceInfo)).toBe(false);225});226227it('returns true for file inside the worktree', () => {228const workspaceInfo: IWorkspaceInfo = {229folder: URI.file('/workspace'),230repository: URI.file('/repo'),231worktree: URI.file('/worktree'),232worktreeProperties: { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: '/repo', worktreePath: '/worktree', version: 1 },233};234expect(isFileFromSessionWorkspace(URI.file('/worktree/src/foo.ts'), workspaceInfo)).toBe(true);235});236237it('returns true for file inside repository when worktree exists', () => {238const workspaceInfo: IWorkspaceInfo = {239folder: URI.file('/workspace'),240repository: URI.file('/repo'),241worktree: URI.file('/worktree'),242worktreeProperties: { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: '/repo', worktreePath: '/worktree', version: 1 },243};244expect(isFileFromSessionWorkspace(URI.file('/repo/src/foo.ts'), workspaceInfo)).toBe(true);245});246247it('returns false for file inside repository when no worktree exists', () => {248const workspaceInfo: IWorkspaceInfo = {249folder: URI.file('/workspace'),250repository: URI.file('/repo'),251worktree: undefined,252worktreeProperties: undefined,253};254expect(isFileFromSessionWorkspace(URI.file('/repo/src/foo.ts'), workspaceInfo)).toBe(false);255});256257it('returns false when workspaceInfo has no folder, no repository, no worktree', () => {258const workspaceInfo: IWorkspaceInfo = {259folder: undefined,260repository: undefined,261worktree: undefined,262worktreeProperties: undefined,263};264expect(isFileFromSessionWorkspace(URI.file('/any/file.ts'), workspaceInfo)).toBe(false);265});266});267268describe('handleReadPermission', () => {269let logService: ILogService;270let token: CancellationToken;271let tokenSource: CancellationTokenSource;272273beforeEach(() => {274const services = disposables.add(createExtensionUnitTestingServices());275const accessor = services.createTestingAccessor();276logService = accessor.get(ILogService);277tokenSource = new CancellationTokenSource();278token = tokenSource.token;279});280281afterEach(() => {282tokenSource.dispose();283});284285function makeWorkspaceInfo(folder?: URI, worktree?: URI, repository?: URI): IWorkspaceInfo {286return {287folder,288repository,289worktree,290worktreeProperties: worktree ? { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: repository?.fsPath ?? '', worktreePath: worktree.fsPath, version: 1 } : undefined,291};292}293294function makeImageSupport(trusted: boolean): ICopilotCLIImageSupport {295return { _serviceBrand: undefined, storeImage: vi.fn(), isTrustedImage: () => trusted };296}297298function makeWorkspaceService(folders: URI[]): IWorkspaceService {299return { getWorkspaceFolder: (resource: URI) => folders.find(f => resource.fsPath.startsWith(f.fsPath)) } as unknown as IWorkspaceService;300}301302function makeToolsService(response: string): IToolsService {303return {304invokeTool: vi.fn(async () => new LanguageModelToolResult2([new LanguageModelTextPart(response)])),305} as unknown as IToolsService;306}307308it('auto-approves trusted images', async () => {309const req = { kind: 'read', path: '/images/cat.png' } as any;310const result = await handleReadPermission(311'session-1', req, undefined, [], makeImageSupport(true),312makeWorkspaceInfo(), makeWorkspaceService([]), makeToolsService('no'),313undefined as unknown as ChatParticipantToolToken, logService, token,314);315expect(result.kind).toBe('approve-once');316});317318it('auto-approves files in session workspace (folder)', async () => {319const req = { kind: 'read', path: '/workspace/src/file.ts' } as any;320const result = await handleReadPermission(321'session-1', req, undefined, [], makeImageSupport(false),322makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]),323makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token,324);325expect(result.kind).toBe('approve-once');326});327328it('auto-approves files in a VS Code workspace folder', async () => {329const req = { kind: 'read', path: '/vscode-ws/src/file.ts' } as any;330const result = await handleReadPermission(331'session-1', req, undefined, [], makeImageSupport(false),332makeWorkspaceInfo(URI.file('/other')), makeWorkspaceService([URI.file('/vscode-ws')]),333makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token,334);335expect(result.kind).toBe('approve-once');336});337338it('auto-approves attached files', async () => {339const filePath = '/external/attached.ts';340const req = { kind: 'read', path: filePath } as any;341const attachments = [{ type: 'file', path: filePath }] as any;342const result = await handleReadPermission(343'session-1', req, undefined, attachments, makeImageSupport(false),344makeWorkspaceInfo(), makeWorkspaceService([]),345makeToolsService('no'), undefined as unknown as ChatParticipantToolToken, logService, token,346);347expect(result.kind).toBe('approve-once');348});349350it('falls back to confirmation tool for out-of-workspace reads and approves on "yes"', async () => {351const toolsService = makeToolsService('yes');352const req = { kind: 'read', path: '/external/secret.txt', intention: 'Read config' } as any;353const result = await handleReadPermission(354'session-1', req, undefined, [], makeImageSupport(false),355makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]),356toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,357);358expect(result.kind).toBe('approve-once');359expect(toolsService.invokeTool).toHaveBeenCalled();360});361362it('denies when confirmation tool returns non-"yes"', async () => {363const toolsService = makeToolsService('no');364const req = { kind: 'read', path: '/external/secret.txt' } as any;365const result = await handleReadPermission(366'session-1', req, undefined, [], makeImageSupport(false),367makeWorkspaceInfo(URI.file('/workspace')), makeWorkspaceService([]),368toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,369);370expect(result.kind).toBe('denied-interactively-by-user');371});372373it('uses intention as message when available', async () => {374const toolsService = makeToolsService('yes');375const req = { kind: 'read', path: '/external/file.txt', intention: 'Read 3 config files' } as any;376await handleReadPermission(377'session-1', req, undefined, [], makeImageSupport(false),378makeWorkspaceInfo(), makeWorkspaceService([]),379toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,380);381const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];382expect(callArgs[0]).toBe(ToolName.CoreConfirmationTool);383expect(callArgs[1].input.message).toBe('Read 3 config files');384});385386it('falls back to path when no intention', async () => {387const toolsService = makeToolsService('yes');388const req = { kind: 'read', path: '/external/file.txt' } as any;389await handleReadPermission(390'session-1', req, undefined, [], makeImageSupport(false),391makeWorkspaceInfo(), makeWorkspaceService([]),392toolsService, undefined as unknown as ChatParticipantToolToken, logService, token,393);394const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];395expect(callArgs[1].input.message).toBe('/external/file.txt');396});397});398399describe('handleWritePermission', () => {400let logService: ILogService;401let token: CancellationToken;402let tokenSource: CancellationTokenSource;403let editTracker: ExternalEditTracker;404405beforeEach(() => {406const services = disposables.add(createExtensionUnitTestingServices());407const accessor = services.createTestingAccessor();408logService = accessor.get(ILogService);409tokenSource = new CancellationTokenSource();410token = tokenSource.token;411editTracker = new ExternalEditTracker();412editTracker.trackEdit = vi.fn(async () => { });413});414415afterEach(() => {416tokenSource.dispose();417});418419function makeWorkspaceInfo(opts: { folder?: URI; worktree?: URI; repository?: URI; worktreeProperties?: any } = {}): IWorkspaceInfo {420return {421folder: opts.folder,422repository: opts.repository,423worktree: opts.worktree,424worktreeProperties: opts.worktreeProperties,425};426}427428function makeWorkspaceService(folders: URI[]): IWorkspaceService {429return { getWorkspaceFolder: (resource: URI) => folders.find(f => resource.fsPath.startsWith(f.fsPath)) } as unknown as IWorkspaceService;430}431432function makeToolsService(response: string): IToolsService {433return {434invokeTool: vi.fn(async () => new LanguageModelToolResult2([new LanguageModelTextPart(response)])),435} as unknown as IToolsService;436}437438it('auto-approves writes in workspace folder for non-protected files', async () => {439const wsFolder = URI.file('/workspace');440const req = { kind: 'write', fileName: URI.file('/workspace/src/foo.ts').fsPath, diff: '', intention: '' } as any;441const result = await handleWritePermission(442'session-1', req, undefined, undefined, undefined, editTracker,443makeWorkspaceInfo({ folder: wsFolder }),444makeWorkspaceService([wsFolder]),445instaService, makeToolsService('no'),446undefined as unknown as ChatParticipantToolToken, logService, token,447);448expect(result.kind).toBe('approve-once');449});450451it('auto-approves writes in working directory when isolation is enabled', async () => {452const worktree = URI.file('/worktree');453const req = { kind: 'write', fileName: URI.file('/worktree/src/foo.ts').fsPath, diff: '', intention: '' } as any;454const result = await handleWritePermission(455'session-1', req, undefined, undefined, undefined, editTracker,456makeWorkspaceInfo({457folder: URI.file('/workspace'),458worktree,459worktreeProperties: { autoCommit: true, baseCommit: 'abc', branchName: 'test', repositoryPath: '/repo', worktreePath: '/worktree', version: 1 },460}),461makeWorkspaceService([]),462instaService, makeToolsService('no'),463undefined as unknown as ChatParticipantToolToken, logService, token,464);465expect(result.kind).toBe('approve-once');466});467468it('falls back to confirmation for writes outside workspace', async () => {469const toolsService = makeToolsService('yes');470const req = { kind: 'write', fileName: URI.file('/external/foo.ts').fsPath, diff: '', intention: '' } as any;471const result = await handleWritePermission(472'session-1', req, undefined, undefined, undefined, editTracker,473makeWorkspaceInfo({ folder: URI.file('/workspace') }),474makeWorkspaceService([URI.file('/workspace')]),475instaService, toolsService,476undefined as unknown as ChatParticipantToolToken, logService, token,477);478expect(result.kind).toBe('approve-once');479expect(toolsService.invokeTool).toHaveBeenCalled();480});481482it('denies writes outside workspace when user declines confirmation', async () => {483const toolsService = makeToolsService('no');484const req = { kind: 'write', fileName: URI.file('/external/foo.ts').fsPath, diff: '', intention: '' } as any;485const result = await handleWritePermission(486'session-1', req, undefined, undefined, undefined, editTracker,487makeWorkspaceInfo({ folder: URI.file('/workspace') }),488makeWorkspaceService([URI.file('/workspace')]),489instaService, toolsService,490undefined as unknown as ChatParticipantToolToken, logService, token,491);492expect(result.kind).toBe('denied-interactively-by-user');493});494495it('auto-approves when no file can be determined (no fileName, no toolCall)', async () => {496const req = { kind: 'write', intention: 'some write' } as any;497const result = await handleWritePermission(498'session-1', req, undefined, undefined, undefined, editTracker,499makeWorkspaceInfo({ folder: URI.file('/workspace') }),500makeWorkspaceService([URI.file('/workspace')]),501instaService, makeToolsService('no'),502undefined as unknown as ChatParticipantToolToken, logService, token,503);504// No file => getFileEditConfirmationToolParams returns undefined => auto-approve505expect(result.kind).toBe('approve-once');506});507});508509describe('showInteractivePermissionPrompt', () => {510let logService: ILogService;511let token: CancellationToken;512let tokenSource: CancellationTokenSource;513514beforeEach(() => {515const services = disposables.add(createExtensionUnitTestingServices());516const accessor = services.createTestingAccessor();517logService = accessor.get(ILogService);518tokenSource = new CancellationTokenSource();519token = tokenSource.token;520});521522afterEach(() => {523tokenSource.dispose();524});525526function makeToolsService(response: string): IToolsService {527return {528invokeTool: vi.fn(async () => new LanguageModelToolResult2([new LanguageModelTextPart(response)])),529} as unknown as IToolsService;530}531532it('approves when user confirms with "yes"', async () => {533const toolsService = makeToolsService('yes');534const req = { kind: 'url', url: 'https://example.com' } as any;535const result = await showInteractivePermissionPrompt(536req, undefined, toolsService,537undefined as unknown as ChatParticipantToolToken, logService, token,538);539expect(result.kind).toBe('approve-once');540const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];541expect(callArgs[0]).toBe(ToolName.CoreConfirmationTool);542expect(callArgs[1].input.title).toBe('Copilot CLI Permission Request');543});544545it('denies when user declines', async () => {546const toolsService = makeToolsService('no');547const req = { kind: 'url', url: 'https://example.com' } as any;548const result = await showInteractivePermissionPrompt(549req, undefined, toolsService,550undefined as unknown as ChatParticipantToolToken, logService, token,551);552expect(result.kind).toBe('denied-interactively-by-user');553});554555it('denies when invokeTool throws', async () => {556const toolsService = {557invokeTool: vi.fn(async () => { throw new Error('tool failure'); }),558} as unknown as IToolsService;559const req = { kind: 'url', url: 'https://example.com' } as any;560const result = await showInteractivePermissionPrompt(561req, undefined, toolsService,562undefined as unknown as ChatParticipantToolToken, logService, token,563);564expect(result.kind).toBe('denied-interactively-by-user');565});566567it('passes toolParentCallId as subAgentInvocationId', async () => {568const toolsService = makeToolsService('yes');569const req = { kind: 'url', url: 'https://example.com' } as any;570await showInteractivePermissionPrompt(571req, 'parent-123', toolsService,572undefined as unknown as ChatParticipantToolToken, logService, token,573);574const callArgs = (toolsService.invokeTool as ReturnType<typeof vi.fn>).mock.calls[0];575expect(callArgs[1].subAgentInvocationId).toBe('parent-123');576});577578it('approves with case-insensitive "Yes"', async () => {579const toolsService = makeToolsService('Yes');580const req = { kind: 'url', url: 'https://example.com' } as any;581const result = await showInteractivePermissionPrompt(582req, undefined, toolsService,583undefined as unknown as ChatParticipantToolToken, logService, token,584);585expect(result.kind).toBe('approve-once');586});587});588});589590591