diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index efd25c0dda4..071bc742916 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "@anthropic-ai/claude-code": "^1.0.89", + "@anthropic-ai/claude-code": "1.0.93", "@anthropic-ai/sdk": "^0.56.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", @@ -153,9 +153,9 @@ } }, "node_modules/@anthropic-ai/claude-code": { - "version": "1.0.89", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.89.tgz", - "integrity": "sha512-FKzFA0whQ1oVqdq3HG7gE3aojcZfGxrhza9z7OMDUFm4YMADHQxn6TWxWss5dhzXze7vd+QOn8CuH+uHnhAr4w==", + "version": "1.0.93", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-1.0.93.tgz", + "integrity": "sha512-HSrbuYVu4k1dwoj/IYsXEVSoMWDPujy2D4zl9BMt4Zt0kwUwZch0nHpTyQ0C+YeHMN7hHbViz0bw6spg0a5GgQ==", "license": "SEE LICENSE IN README.md", "bin": { "claude": "cli.js" diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index c5cfd3fbca3..d28f1735405 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -3802,7 +3802,11 @@ "name": "claude", "displayName": "Claude Code", "description": "The Claude Code agent", - "when": "config.github.copilot.chat.advanced.claudeCode.enabled" + "when": "config.github.copilot.chat.advanced.claudeCode.enabled", + "capabilities": { + "supportsFileAttachments": true, + "supportsToolAttachments": false + } } ], "debuggers": [ @@ -3981,7 +3985,7 @@ "zeromq": "github:rebornix/zeromq.js#a19e8e373b3abc677f91b936d3f00d49b1b61792" }, "dependencies": { - "@anthropic-ai/claude-code": "^1.0.89", + "@anthropic-ai/claude-code": "1.0.93", "@anthropic-ai/sdk": "^0.56.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", diff --git a/extensions/copilot/src/extension/agents/claude/common/constants.ts b/extensions/copilot/src/extension/agents/claude/common/constants.ts new file mode 100644 index 00000000000..fa010815bfe --- /dev/null +++ b/extensions/copilot/src/extension/agents/claude/common/constants.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export enum ClaudeToolNames { + Bash = 'Bash', + Read = 'Read', + Glob = 'Glob', + Grep = 'Grep', + LS = 'LS', + Edit = 'Edit', +} \ No newline at end of file diff --git a/extensions/copilot/src/extension/agents/claude/common/toolInvocationFormatter.ts b/extensions/copilot/src/extension/agents/claude/common/toolInvocationFormatter.ts new file mode 100644 index 00000000000..996b2b8451f --- /dev/null +++ b/extensions/copilot/src/extension/agents/claude/common/toolInvocationFormatter.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import Anthropic from '@anthropic-ai/sdk'; +import * as l10n from '@vscode/l10n'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ChatToolInvocationPart, MarkdownString } from '../../../../vscodeTypes'; +import { ClaudeToolNames } from './constants'; + +/** + * Creates a formatted tool invocation part based on the tool type and input + */ +export function createFormattedToolInvocation( + toolUse: Anthropic.ToolUseBlock, + toolResult?: Anthropic.ToolResultBlockParam +): ChatToolInvocationPart { + const invocation = new ChatToolInvocationPart(toolUse.name, toolUse.id, false); + invocation.isConfirmed = true; + + if (toolResult) { + invocation.isError = toolResult.is_error; // Currently unused! + } + + if (toolUse.name === ClaudeToolNames.Bash) { + formatBashInvocation(invocation, toolUse); + } else if (toolUse.name === ClaudeToolNames.Read) { + formatReadInvocation(invocation, toolUse); + } else if (toolUse.name === ClaudeToolNames.Glob) { + formatGlobInvocation(invocation, toolUse); + } else if (toolUse.name === ClaudeToolNames.Grep) { + formatGrepInvocation(invocation, toolUse); + } else if (toolUse.name === ClaudeToolNames.LS) { + formatLSInvocation(invocation, toolUse); + } else if (toolUse.name === ClaudeToolNames.Edit) { + formatEditInvocation(invocation, toolUse); + } else { + formatGenericInvocation(invocation, toolUse); + } + + return invocation; +} + +function formatBashInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + invocation.invocationMessage = ''; + invocation.toolSpecificData = { + commandLine: { + original: (toolUse.input as any)?.command, + }, + language: 'bash' + }; +} + +function formatReadInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + const filePath = (toolUse.input as any)?.file_path; + invocation.invocationMessage = new MarkdownString(l10n.t(`Read ${filePath ? formatUriForMessage(filePath) : 'file'}`)); +} + +function formatGlobInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + invocation.invocationMessage = new MarkdownString(l10n.t(`Searched for files matching \`${(toolUse.input as any)?.pattern}\``)); +} + +function formatGrepInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + invocation.invocationMessage = new MarkdownString(l10n.t(`Searched text for \`${(toolUse.input as any)?.pattern}\``)); +} + +function formatLSInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + const path = (toolUse.input as any)?.path; + invocation.invocationMessage = new MarkdownString(l10n.t(`Read ${path ? formatUriForMessage(path) : 'dir'}`)); +} + +function formatEditInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + const filePath = (toolUse.input as any)?.file_path; + invocation.invocationMessage = new MarkdownString(l10n.t(`Edited ${filePath ? formatUriForMessage(filePath) : 'file'}`)); +} + +function formatGenericInvocation(invocation: ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { + invocation.invocationMessage = l10n.t(`Used tool: ${toolUse.name}`); +} + +function formatUriForMessage(path: string): string { + return `[](${URI.file(path).toString()})`; +} \ No newline at end of file diff --git a/extensions/copilot/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts b/extensions/copilot/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts index 46b6d921da4..04b10c10213 100644 --- a/extensions/copilot/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Options, SDKUserMessage } from '@anthropic-ai/claude-code'; +import Anthropic from '@anthropic-ai/sdk'; import * as vscode from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { IEnvService } from '../../../../platform/env/common/envService'; @@ -12,8 +13,12 @@ import { IWorkspaceService } from '../../../../platform/workspace/common/workspa import { DeferredPromise } from '../../../../util/vs/base/common/async'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import { isWindows } from '../../../../util/vs/base/common/platform'; +import { URI } from '../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { isFileOkForTool } from '../../../tools/node/toolUtils'; import { ILanguageModelServerConfig, LanguageModelServer } from '../../vscode-node/langModelServer'; +import { ClaudeToolNames } from '../common/constants'; +import { createFormattedToolInvocation } from '../common/toolInvocationFormatter'; // Manages Claude Code agent interactions and language model server lifecycle export class ClaudeAgentManager extends Disposable { @@ -62,13 +67,16 @@ export class ClaudeAgentManager extends Disposable { class KnownClaudeError extends Error { } class ClaudeCodeSession { + private static DenyToolMessage = 'The user declined to run the tool'; + constructor( private readonly serverConfig: ILanguageModelServerConfig, public sessionId: string | undefined, @ILogService private readonly logService: ILogService, @IConfigurationService private readonly configService: IConfigurationService, @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IEnvService private readonly envService: IEnvService + @IEnvService private readonly envService: IEnvService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { } public async invoke( @@ -127,6 +135,7 @@ class ClaudeCodeSession { await def.p; } + const unprocessedToolCalls = new Map(); for await (const message of query({ prompt: createPromptIterable(prompt, this.sessionId), options @@ -141,17 +150,24 @@ class ClaudeCodeSession { if (item.type === 'text' && item.text) { stream.markdown(item.text); } else if (item.type === 'tool_use') { - // currentToolTask?.complete(); - // currentToolTask = new DeferredPromise(); - stream.markdown(`\n\n🛠️ Using tool: ${item.name}...`); - stream.prepareToolInvocation(item.name); + stream.progress(`\n\n🛠️ Using tool: ${item.name}...`); + unprocessedToolCalls.set(item.id, item); } } } else if (message.type === 'user') { if (Array.isArray(message.message.content)) { - for (const item of message.message.content) { - if (item.type === 'tool_result') { - // currentToolTask?.complete(); + for (const toolResult of message.message.content) { + if (toolResult.type === 'tool_result') { + const toolUse = unprocessedToolCalls.get(toolResult.tool_use_id); + if (toolUse) { + unprocessedToolCalls.delete(toolResult.tool_use_id); + const invocation = createFormattedToolInvocation(toolUse, toolResult); + if (toolResult?.content === ClaudeCodeSession.DenyToolMessage) { + invocation.isConfirmed = false; + } + + stream.push(invocation); + } } } } @@ -170,24 +186,67 @@ class ClaudeCodeSession { * Handles tool permission requests by showing a confirmation dialog to the user */ private async canUseTool(toolName: string, input: Record, toolInvocationToken: vscode.ChatParticipantToolToken): Promise<{ behavior: 'allow'; updatedInput: Record } | { behavior: 'deny'; message: string }> { - this.logService.trace(`Claude CLI SDK: canUseTool: ${toolName}`); - try { - await vscode.lm.invokeTool('vscode_get_confirmation', { - input: { - title: `Use ${toolName}?`, - message: `\`\`\`\n${JSON.stringify(input, null, 2)}\n\`\`\`` - }, - toolInvocationToken, - }); + this.logService.trace(`ClaudeCodeSession: canUseTool: ${toolName}(${JSON.stringify(input)})`); + if (await this.canAutoApprove(toolName, input)) { + this.logService.trace(`ClaudeCodeSession: auto-approving ${toolName}`); + return { behavior: 'allow', updatedInput: input }; - } catch { + } + + try { + const result = await vscode.lm.invokeTool('vscode_get_confirmation', { + input: this.getConfirmationToolParams(toolName, input), + toolInvocationToken, + }); + const firstResultPart = result.content.at(0); + if (firstResultPart instanceof vscode.LanguageModelTextPart && firstResultPart.value === 'yes') { + return { + behavior: 'allow', + updatedInput: input + }; + } + } catch { } + return { + behavior: 'deny', + message: ClaudeCodeSession.DenyToolMessage + }; + } + + private getConfirmationToolParams(toolName: string, input: Record): IConfirmationToolParams { + if (toolName === ClaudeToolNames.Bash) { return { - behavior: 'deny', - message: 'The user declined to run the tool' + title: `Use ${toolName}?`, + message: `\`\`\`\n${JSON.stringify(input, null, 2)}\n\`\`\``, + confirmationType: 'terminal', + terminalCommand: input.command as string | undefined }; } + + return { + title: `Use ${toolName}?`, + message: `\`\`\`\n${JSON.stringify(input, null, 2)}\n\`\`\``, + confirmationType: 'basic' + }; } + + private async canAutoApprove(toolName: string, input: Record): Promise { + if (toolName === ClaudeToolNames.Edit) { + return await this.instantiationService.invokeFunction(isFileOkForTool, URI.file(input.file_path as string)); + } + + return false; + } +} + +/** + * Tool params from core + */ +interface IConfirmationToolParams { + title: string; + message: string; + confirmationType?: 'basic' | 'terminal'; + terminalCommand?: string; } \ No newline at end of file diff --git a/extensions/copilot/src/extension/agents/vscode-node/adapters/anthropicAdapter.ts b/extensions/copilot/src/extension/agents/vscode-node/adapters/anthropicAdapter.ts index a888f464d03..85a5cb709f7 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/adapters/anthropicAdapter.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/adapters/anthropicAdapter.ts @@ -231,7 +231,7 @@ class AnthropicAdapter implements IProtocolAdapter { cache_read_input_tokens: 0, output_tokens: 1, service_tier: null, - server_tool_use: null + server_tool_use: null, } } }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 05839620db6..12b19744ca9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -7,8 +7,8 @@ import { SDKMessage } from '@anthropic-ai/claude-code'; import Anthropic from '@anthropic-ai/sdk'; import * as vscode from 'vscode'; import { coalesce } from '../../../util/vs/base/common/arrays'; -import { URI } from '../../../util/vs/base/common/uri'; -import { ChatRequestTurn2, MarkdownString } from '../../../vscodeTypes'; +import { ChatRequestTurn2 } from '../../../vscodeTypes'; +import { createFormattedToolInvocation } from '../../agents/claude/common/toolInvocationFormatter'; import { IClaudeCodeSession, IClaudeCodeSessionService } from '../../agents/claude/node/claudeCodeSessionService'; import { ClaudeAgentManager } from '../../agents/claude/vscode-node/claudeCodeAgent'; import { ClaudeSessionDataStore } from './claudeChatSessionItemProvider'; @@ -27,26 +27,32 @@ export class ClaudeChatSessionContentProvider implements vscode.ChatSessionConte ) { } async provideChatSessionContent(internalSessionId: string, token: vscode.CancellationToken): Promise { - const initialPrompt = this.sessionStore.getAndConsumeInitialPrompt(internalSessionId); + const initialRequest = this.sessionStore.getAndConsumeInitialRequest(internalSessionId); const existingSession = await this.sessionService.getSession(internalSessionId, token); const toolContext = this._createToolContext(); const history = this._buildChatHistory(existingSession, toolContext); - + if (initialRequest) { + history.push(new ChatRequestTurn2(initialRequest.prompt, undefined, [], '', [], undefined)); + } return { history, // This is called to attach to a previous or new session- send a request if it's a new session - activeResponseCallback: initialPrompt ? + activeResponseCallback: initialRequest ? async (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { - const request = this._createInitialChatRequest(initialPrompt); + const request = this._createInitialChatRequest(initialRequest, internalSessionId); const result = await this.claudeAgentManager.handleRequest(undefined, request, { history: [] }, stream, token); if (result.claudeSessionId) { this.sessionStore.setClaudeSessionId(internalSessionId, result.claudeSessionId); } } : undefined, - requestHandler: (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { + requestHandler: async (request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => { const claudeSessionId = this.sessionStore.getSessionId(internalSessionId); - return this.claudeAgentManager.handleRequest(claudeSessionId, request, context, stream, token); + const result = await this.claudeAgentManager.handleRequest(claudeSessionId, request, context, stream, token); + if (result.claudeSessionId) { + this.sessionStore.setClaudeSessionId(internalSessionId, result.claudeSessionId); + } + return result; } }; } @@ -80,22 +86,12 @@ export class ClaudeChatSessionContentProvider implements vscode.ChatSessionConte } private _finishToolInvocationPart(toolUse: Anthropic.ToolUseBlock, toolResult: Anthropic.ToolResultBlockParam, pendingInvocation: vscode.ChatToolInvocationPart) { - pendingInvocation.isError = toolResult.is_error; - if (toolUse.name === 'Bash') { - this._formatBashInvocation(pendingInvocation, toolUse); - } else if (toolUse.name === 'Read') { - this._formatReadInvocation(pendingInvocation, toolUse); - } else if (toolUse.name === 'Glob') { - this._formatGlobInvocation(pendingInvocation, toolUse); - } else if (toolUse.name === 'Grep') { - this._formatGrepInvocation(pendingInvocation, toolUse); - } else if (toolUse.name === 'LS') { - this._formatLSInvocation(pendingInvocation, toolUse); - } else if (toolUse.name === 'Edit') { - this._formatEditInvocation(pendingInvocation, toolUse); - } else { - this._formatGenericInvocation(pendingInvocation, toolUse); - } + const formattedInvocation = createFormattedToolInvocation(toolUse, toolResult); + + // Copy formatting from the utility function + pendingInvocation.isError = formattedInvocation.isError; + pendingInvocation.invocationMessage = formattedInvocation.invocationMessage; + pendingInvocation.toolSpecificData = formattedInvocation.toolSpecificData; } private _createToolContext(): ToolContext { @@ -119,23 +115,11 @@ export class ClaudeChatSessionContentProvider implements vscode.ChatSessionConte })); } - private _createInitialChatRequest(initialPrompt: string | undefined): vscode.ChatRequest { + private _createInitialChatRequest(initialRequest: vscode.ChatRequest, internalSessionId: string): vscode.ChatRequest { return { - attempt: 0, - command: undefined, - enableCommandDetection: false, - id: '', - isParticipantDetected: false, - location: vscode.ChatLocation.Panel, - location2: undefined, - model: null!, - prompt: initialPrompt ?? '', - references: [], - toolReferences: [], - tools: new Map(), - acceptedConfirmationData: undefined, - editedFileEvents: undefined, - toolInvocationToken: {} as never + ...initialRequest, + // TODO this does not work + toolInvocationToken: { sessionId: internalSessionId } as vscode.ChatParticipantToolToken }; } @@ -171,44 +155,4 @@ export class ClaudeChatSessionContentProvider implements vscode.ChatSessionConte } } - private _formatBashInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - invocation.invocationMessage = ''; - invocation.toolSpecificData = { - commandLine: { - original: (toolUse.input as any)?.command, - }, - language: 'bash' - }; - } - - private _formatReadInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - const filePath = (toolUse.input as any)?.file_path; - invocation.invocationMessage = new MarkdownString(vscode.l10n.t(`Read ${filePath ? this._formatUriForMessage(filePath) : 'file'}`)); - } - - private _formatGlobInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - invocation.invocationMessage = new MarkdownString(vscode.l10n.t(`Searched for files matching \`${(toolUse.input as any)?.pattern}\``)); - } - - private _formatGrepInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - invocation.invocationMessage = new MarkdownString(vscode.l10n.t(`Searched text for \`${(toolUse.input as any)?.pattern}\``)); - } - - private _formatLSInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - const path = (toolUse.input as any)?.path; - invocation.invocationMessage = new MarkdownString(vscode.l10n.t(`Read ${path ? this._formatUriForMessage(path) : 'dir'}`)); - } - - private _formatEditInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - const filePath = (toolUse.input as any)?.file_path; - invocation.invocationMessage = new MarkdownString(vscode.l10n.t(`Edited ${filePath ? this._formatUriForMessage(filePath) : 'file'}`)); - } - - private _formatGenericInvocation(invocation: vscode.ChatToolInvocationPart, toolUse: Anthropic.ToolUseBlock): void { - invocation.invocationMessage = vscode.l10n.t(`Used tool: ${toolUse.name}`); - } - - private _formatUriForMessage(path: string): string { - return `[](${URI.file(path).toString()})`; - } } \ No newline at end of file diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts index bbb7e418013..409972b69be 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionItemProvider.ts @@ -12,7 +12,7 @@ import { IClaudeCodeSessionService } from '../../agents/claude/node/claudeCodeSe export class ClaudeSessionDataStore { private static StorageKey = 'claudeSessionIds'; - private _internalSessionToInitialPrompt: Map = new Map(); + private _internalSessionToInitialRequest: Map = new Map(); private _unresolvedNewSessions = new Map(); constructor( @@ -43,13 +43,13 @@ export class ClaudeSessionDataStore { return id; } - public setInitialPrompt(internalSessionId: string, prompt: string) { - this._internalSessionToInitialPrompt.set(internalSessionId, prompt); + public setInitialRequest(internalSessionId: string, request: vscode.ChatRequest) { + this._internalSessionToInitialRequest.set(internalSessionId, request); } - public getAndConsumeInitialPrompt(sessionId: string): string | undefined { - const prompt = this._internalSessionToInitialPrompt.get(sessionId); - this._internalSessionToInitialPrompt.delete(sessionId); + public getAndConsumeInitialRequest(sessionId: string): vscode.ChatRequest | undefined { + const prompt = this._internalSessionToInitialRequest.get(sessionId); + this._internalSessionToInitialRequest.delete(sessionId); return prompt; } @@ -103,6 +103,7 @@ export class ClaudeChatSessionItemProvider extends Disposable implements vscode. } public async provideNewChatSessionItem(options: { + readonly request: vscode.ChatRequest; readonly prompt?: string; readonly history?: ReadonlyArray; metadata?: any; @@ -110,8 +111,8 @@ export class ClaudeChatSessionItemProvider extends Disposable implements vscode. const label = options.prompt ?? 'Claude Code'; const internal = this.sessionStore.registerNewSession(label); this._onDidChangeChatSessionItems.fire(); - if (options.prompt) { - this.sessionStore.setInitialPrompt(internal, options.prompt); + if (options.request) { + this.sessionStore.setInitialRequest(internal, options.request); } return { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/__snapshots__/claudeChatSessionContentProvider.spec.ts.snap b/extensions/copilot/src/extension/chatSessions/vscode-node/test/__snapshots__/claudeChatSessionContentProvider.spec.ts.snap new file mode 100644 index 00000000000..cd7327d0e40 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/__snapshots__/claudeChatSessionContentProvider.spec.ts.snap @@ -0,0 +1,101 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ChatSessionContentProvider > loads real fixture file with tool invocation flow and converts to correct chat history 1`] = ` +[ + { + "prompt": "Add a small comment to ClaudeAgentManager", + "type": "request", + }, + { + "parts": [ + { + "content": "I'll add a small comment to ClaudeAgentManager. Let me first find this file in the codebase.", + "type": "markdown", + }, + ], + "type": "response", + }, + { + "parts": [ + { + "invocationMessage": "Searched for files matching \`**/ClaudeAgentManager*\`", + "isError": undefined, + "toolCallId": "toolu_01Nss2ugwQN7c4sj6hTxYc6F", + "toolName": "Glob", + "type": "tool", + }, + ], + "type": "response", + }, + { + "parts": [ + { + "invocationMessage": "Searched text for \`ClaudeAgentManager\`", + "isError": undefined, + "toolCallId": "toolu_01FqdrDGdxXUWRRLziM7gS2R", + "toolName": "Grep", + "type": "tool", + }, + ], + "type": "response", + }, + { + "parts": [ + { + "content": "Let me check the claudeCodeAgent.ts file which likely contains the ClaudeAgentManager:", + "type": "markdown", + }, + ], + "type": "response", + }, + { + "parts": [ + { + "invocationMessage": "Read [](file:///Users/roblou/code/vscode-copilot-chat/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts)", + "isError": undefined, + "toolCallId": "toolu_0152sKfmLJ5pTuNyeESooT25", + "toolName": "Read", + "type": "tool", + }, + ], + "type": "response", + }, + { + "parts": [ + { + "content": "I'll add a small comment to the ClaudeAgentManager class:", + "type": "markdown", + }, + ], + "type": "response", + }, + { + "parts": [ + { + "invocationMessage": "Edited [](file:///Users/roblou/code/vscode-copilot-chat/src/extension/agents/claude/vscode-node/claudeCodeAgent.ts)", + "isError": undefined, + "toolCallId": "toolu_01NXDY5nya4UzHwUxPnhmQDX", + "toolName": "Edit", + "type": "tool", + }, + ], + "type": "response", + }, + { + "prompt": "now run ls pac* in my terminal", + "type": "request", + }, + { + "parts": [ + { + "invocationMessage": undefined, + "isError": false, + "toolCallId": "toolu_01XiMYfYgoXgkxjDCvc8NZWD", + "toolName": "Bash", + "type": "tool", + }, + ], + "type": "response", + }, +] +`; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts similarity index 89% rename from extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionContentProvider.spec.ts rename to extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 7b3baeb6d74..1f86bc0a99e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -14,7 +14,7 @@ import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspa import { TestLogService } from '../../../../platform/testing/common/testLogService'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; import { URI } from '../../../../util/vs/base/common/uri'; -import { ChatLocation, ChatRequestTurn, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; +import { ChatRequestTurn, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes'; import { ClaudeCodeSessionService, IClaudeCodeSessionService } from '../../../agents/claude/node/claudeCodeSessionService'; import { ClaudeAgentManager } from '../../../agents/claude/vscode-node/claudeCodeAgent'; import { ClaudeChatSessionContentProvider } from '../claudeChatSessionContentProvider'; @@ -50,7 +50,7 @@ describe('ChatSessionContentProvider', () => { } as any; mockSessionStore = { - getAndConsumeInitialPrompt: vi.fn(), + getAndConsumeInitialRequest: vi.fn(), setClaudeSessionId: vi.fn(), getSessionId: vi.fn() } as any; @@ -104,9 +104,10 @@ describe('ChatSessionContentProvider', () => { }); } + const mockInitialRequest: vscode.ChatRequest = { prompt: 'initial prompt' } as Partial as any; describe('provideChatSessionContent', () => { it('returns empty history when no existing session', async () => { - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue('test prompt'); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -129,7 +130,7 @@ describe('ChatSessionContentProvider', () => { ] }; - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(mockSession as any); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -169,7 +170,7 @@ describe('ChatSessionContentProvider', () => { ] }; - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(mockSession as any); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -216,7 +217,7 @@ describe('ChatSessionContentProvider', () => { ] }; - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(mockSession as any); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -240,7 +241,7 @@ describe('ChatSessionContentProvider', () => { }); it('creates activeResponseCallback that calls claudeAgentManager', async () => { - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue('initial prompt'); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(mockInitialRequest); vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); vi.mocked(mockClaudeAgentManager.handleRequest).mockResolvedValue({ claudeSessionId: 'new-claude-session' }); @@ -248,15 +249,13 @@ describe('ChatSessionContentProvider', () => { // Mock stream and test the callback const mockStream = {} as vscode.ChatResponseStream; - if (result.activeResponseCallback) { - await result.activeResponseCallback(mockStream, CancellationToken.None); - } + expect(result.activeResponseCallback).toBeDefined(); + await result.activeResponseCallback!(mockStream, CancellationToken.None); expect(mockClaudeAgentManager.handleRequest).toHaveBeenCalledWith( undefined, expect.objectContaining({ - prompt: 'initial prompt', - location: ChatLocation.Panel + prompt: 'initial prompt' }), { history: [] }, mockStream, @@ -266,8 +265,17 @@ describe('ChatSessionContentProvider', () => { expect(mockSessionStore.setClaudeSessionId).toHaveBeenCalledWith('test-session', 'new-claude-session'); }); + it('not new session - does not have activeResponseCallback', async () => { + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); + vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); + vi.mocked(mockClaudeAgentManager.handleRequest).mockResolvedValue({ claudeSessionId: 'new-claude-session' }); + + const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); + expect(result.activeResponseCallback).toBeUndefined(); + }); + it('creates requestHandler that calls claudeAgentManager with session id', async () => { - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); vi.mocked(mockSessionStore.getSessionId).mockReturnValue('existing-claude-session'); @@ -324,7 +332,7 @@ describe('ChatSessionContentProvider', () => { ] }; - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(mockSession as any); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -406,7 +414,7 @@ describe('ChatSessionContentProvider', () => { ] }; - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(mockSession as any); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -460,7 +468,7 @@ describe('ChatSessionContentProvider', () => { ] }; - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(mockSession as any); const result = await provider.provideChatSessionContent('test-session', CancellationToken.None); @@ -476,7 +484,7 @@ describe('ChatSessionContentProvider', () => { }); it('creates activeResponseCallback that calls claudeAgentManager', async () => { - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue('initial prompt'); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(mockInitialRequest); vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); vi.mocked(mockClaudeAgentManager.handleRequest).mockResolvedValue({ claudeSessionId: 'new-claude-session' }); @@ -491,8 +499,7 @@ describe('ChatSessionContentProvider', () => { expect(mockClaudeAgentManager.handleRequest).toHaveBeenCalledWith( undefined, expect.objectContaining({ - prompt: 'initial prompt', - location: ChatLocation.Panel + prompt: 'initial prompt' }), { history: [] }, mockStream, @@ -503,7 +510,7 @@ describe('ChatSessionContentProvider', () => { }); it('creates requestHandler that calls claudeAgentManager with session id', async () => { - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); vi.mocked(mockSessionStore.getSessionId).mockReturnValue('existing-claude-session'); @@ -562,7 +569,7 @@ describe('ChatSessionContentProvider', () => { realSessionService ); - vi.mocked(mockSessionStore.getAndConsumeInitialPrompt).mockReturnValue(undefined); + vi.mocked(mockSessionStore.getAndConsumeInitialRequest).mockReturnValue(undefined); const result = await provider.provideChatSessionContent('4c289ca8-f8bb-4588-8400-88b78beb784d', CancellationToken.None); expect(mapHistoryForSnapshot(result.history)).toMatchSnapshot(); diff --git a/extensions/copilot/src/extension/tools/node/toolUtils.ts b/extensions/copilot/src/extension/tools/node/toolUtils.ts index bd3f5dab735..c22f7f05533 100644 --- a/extensions/copilot/src/extension/tools/node/toolUtils.ts +++ b/extensions/copilot/src/extension/tools/node/toolUtils.ts @@ -81,6 +81,15 @@ export function resolveToolInputPath(path: string, promptPathRepresentationServi return uri; } +export async function isFileOkForTool(accessor: ServicesAccessor, uri: URI): Promise { + try { + await assertFileOkForTool(accessor, uri); + return true; + } catch { + return false; + } +} + export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI): Promise { const workspaceService = accessor.get(IWorkspaceService); const tabsAndEditorsService = accessor.get(ITabsAndEditorsService);