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:
Rob Lourens
2025-08-30 21:40:59 +00:00
committed by GitHub
parent a35be0b3b2
commit 79bb2e1ab0
11 changed files with 358 additions and 136 deletions
+4 -4
View File
@@ -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"
+6 -2
View File
@@ -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,
}
}
};
@@ -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()})`;
}
}
@@ -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 {
@@ -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",
},
]
`;
@@ -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);