mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-25 18:46:06 +01:00
Stream tool invocations from claude code, improve confirmations (#850)
* Return formatted tool invocations from agent * Fixes around tool confirmations * Fix tests
This commit is contained in:
Generated
+4
-4
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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()})`;
|
||||
}
|
||||
@@ -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<string, Anthropic.ToolUseBlock>();
|
||||
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<string, unknown>, toolInvocationToken: vscode.ChatParticipantToolToken): Promise<{ behavior: 'allow'; updatedInput: Record<string, unknown> } | { 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<string, unknown>): 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<string, unknown>): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+24
-80
@@ -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<vscode.ChatSession> {
|
||||
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()})`;
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -12,7 +12,7 @@ import { IClaudeCodeSessionService } from '../../agents/claude/node/claudeCodeSe
|
||||
|
||||
export class ClaudeSessionDataStore {
|
||||
private static StorageKey = 'claudeSessionIds';
|
||||
private _internalSessionToInitialPrompt: Map<string, string> = new Map();
|
||||
private _internalSessionToInitialRequest: Map<string, vscode.ChatRequest> = new Map();
|
||||
private _unresolvedNewSessions = new Map<string, { id: string; label: string }>();
|
||||
|
||||
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<vscode.ChatRequestTurn | vscode.ChatResponseTurn>;
|
||||
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 {
|
||||
|
||||
+101
@@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
+28
-21
@@ -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<vscode.ChatRequest> 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();
|
||||
@@ -81,6 +81,15 @@ export function resolveToolInputPath(path: string, promptPathRepresentationServi
|
||||
return uri;
|
||||
}
|
||||
|
||||
export async function isFileOkForTool(accessor: ServicesAccessor, uri: URI): Promise<boolean> {
|
||||
try {
|
||||
await assertFileOkForTool(accessor, uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertFileOkForTool(accessor: ServicesAccessor, uri: URI): Promise<void> {
|
||||
const workspaceService = accessor.get(IWorkspaceService);
|
||||
const tabsAndEditorsService = accessor.get(ITabsAndEditorsService);
|
||||
|
||||
Reference in New Issue
Block a user