Files
gemini-cli/packages/cli/src/zed-integration/zedIntegration.ts
Valery Teplyakov 1a8d77329e fix(acp): Initialize config (#18897) (#18898)
Co-authored-by: Mervap <megavaprold@gmail.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-02-19 15:08:09 +00:00

1276 lines
37 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
GeminiChat,
ToolResult,
ToolCallConfirmationDetails,
FilterFilesOptions,
ConversationRecord,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
AuthType,
logToolCall,
convertToFunctionResponse,
ToolConfirmationOutcome,
clearCachedCredentialFile,
isNodeError,
getErrorMessage,
isWithinRoot,
getErrorStatus,
MCPServerConfig,
DiscoveredMCPTool,
StreamEventType,
ToolCallEvent,
debugLogger,
ReadManyFilesTool,
REFERENCE_CONTENT_START,
resolveModel,
createWorkingStdio,
startupProfiler,
Kind,
partListUnionToString,
LlmRole,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { Readable, Writable } from 'node:stream';
import type { Content, Part, FunctionCall } from '@google/genai';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope, loadSettings } from '../config/settings.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
export async function runZedIntegration(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(stdout, stdin);
const connection = new acp.AgentSideConnection(
(connection) => new GeminiAgent(config, settings, argv, connection),
stream,
);
// SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
// We must explicitly await the connection close to flush telemetry.
// Use finally() to ensure cleanup runs even on stream errors.
await connection.closed.finally(runExitCleanup);
}
export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
constructor(
private config: Config,
private settings: LoadedSettings,
private argv: CliArgs,
private connection: acp.AgentSideConnection,
) {}
async initialize(
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
this.clientCapabilities = args.clientCapabilities;
const authMethods = [
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
description: null,
},
{
id: AuthType.USE_GEMINI,
name: 'Use Gemini API key',
description:
'Requires setting the `GEMINI_API_KEY` environment variable',
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
description: null,
},
];
await this.config.initialize();
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
},
};
}
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security.auth.selectedType;
// Only clear credentials when switching to a different auth method
if (selectedAuthType && selectedAuthType !== method) {
await clearCachedCredentialFile();
}
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
// or perform new authentication if needed
try {
await this.config.refreshAuth(method);
} catch (e) {
throw new acp.RequestError(
getErrorStatus(e) || 401,
getAcpErrorMessage(e),
);
}
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
}
async newSession({
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID();
const loadedSettings = loadSettings(cwd);
const config = await this.newSessionConfig(
sessionId,
cwd,
mcpServers,
loadedSettings,
);
const authType =
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(authType);
isAuthenticated = true;
// Extra validation for Gemini API key
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (
authType === AuthType.USE_GEMINI &&
(!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
) {
isAuthenticated = false;
authErrorMessage = 'Gemini API key is missing or not configured.';
}
} catch (e) {
isAuthenticated = false;
authErrorMessage = getAcpErrorMessage(e);
debugLogger.error(
`Authentication failed: ${e instanceof Error ? e.stack : e}`,
);
}
if (!isAuthenticated) {
throw new acp.RequestError(
401,
authErrorMessage || 'Authentication required.',
);
}
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
await config.initialize();
startupProfiler.flush(config);
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.connection);
this.sessions.set(sessionId, session);
return {
sessionId,
};
}
async loadSession({
sessionId,
cwd,
mcpServers,
}: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse> {
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
);
const sessionSelector = new SessionSelector(config);
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const { clientHistory } = convertSessionToHistoryFormats(
sessionData.messages,
);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
await geminiClient.resumeChat(clientHistory, {
conversation: sessionData,
filePath: sessionPath,
});
const session = new Session(
sessionId,
geminiClient.getChat(),
config,
this.connection,
);
this.sessions.set(sessionId, session);
// Stream history back to client
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
return {};
}
private async initializeSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
): Promise<Config> {
const selectedAuthType = this.settings.merged.security.auth.selectedType;
if (!selectedAuthType) {
throw acp.RequestError.authRequired();
}
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
// 3. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
return config;
}
async newSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const currentSettings = loadedSettings || this.settings;
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
for (const server of mcpServers) {
if (
'type' in server &&
(server.type === 'sse' || server.type === 'http')
) {
// HTTP or SSE MCP server
const headers = Object.fromEntries(
server.headers.map(({ name, value }) => [name, value]),
);
mergedMcpServers[server.name] = new MCPServerConfig(
undefined, // command
undefined, // args
undefined, // env
undefined, // cwd
server.type === 'sse' ? server.url : undefined, // url (sse)
server.type === 'http' ? server.url : undefined, // httpUrl
headers,
);
} else if ('command' in server) {
// Stdio MCP server
const env: Record<string, string> = {};
for (const { name: envName, value } of server.env) {
env[envName] = value;
}
mergedMcpServers[server.name] = new MCPServerConfig(
server.command,
server.args,
env,
cwd,
);
}
}
const settings = {
...currentSettings.merged,
mcpServers: mergedMcpServers,
};
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
return config;
}
async cancel(params: acp.CancelNotification): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
await session.cancelPendingPrompt();
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.prompt(params);
}
}
export class Session {
private pendingPrompt: AbortController | null = null;
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
) {}
async cancelPendingPrompt(): Promise<void> {
if (!this.pendingPrompt) {
throw new Error('Not currently generating');
}
this.pendingPrompt.abort();
this.pendingPrompt = null;
}
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
if (msg.type === 'user') {
if (contentString.trim()) {
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: contentString },
});
}
} else if (msg.type === 'gemini') {
// Thoughts
if (msg.thoughts) {
for (const thought of msg.thoughts) {
const thoughtText = `**${thought.subject}**\n${thought.description}`;
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: thoughtText },
});
}
}
// Message text
if (contentString.trim()) {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: contentString },
});
}
// Tool calls
if (msg.toolCalls) {
for (const toolCall of msg.toolCalls) {
const toolCallContent: acp.ToolCallContent[] = [];
if (toolCall.resultDisplay) {
if (typeof toolCall.resultDisplay === 'string') {
toolCallContent.push({
type: 'content',
content: { type: 'text', text: toolCall.resultDisplay },
});
} else if ('fileName' in toolCall.resultDisplay) {
toolCallContent.push({
type: 'diff',
path: toolCall.resultDisplay.fileName,
oldText: toolCall.resultDisplay.originalContent,
newText: toolCall.resultDisplay.newContent,
});
}
}
const tool = this.config.getToolRegistry().getTool(toolCall.name);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: toolCall.id,
status:
toolCall.status === CoreToolCallStatus.Success
? 'completed'
: 'failed',
title: toolCall.displayName || toolCall.name,
content: toolCallContent,
kind: tool ? toAcpToolKind(tool.kind) : 'other',
});
}
}
}
}
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
const promptId = Math.random().toString(16).slice(2);
const chat = this.chat;
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
if (pendingSend.signal.aborted) {
chat.addHistory(nextMessage);
return { stopReason: CoreToolCallStatus.Cancelled };
}
const functionCalls: FunctionCall[] = [];
try {
const model = resolveModel(this.config.getModel());
const responseStream = await chat.sendMessageStream(
{ model },
nextMessage?.parts ?? [],
promptId,
pendingSend.signal,
LlmRole.MAIN,
);
nextMessage = null;
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
resp.value.candidates.length > 0
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) {
continue;
}
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
}
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
}
}
if (pendingSend.signal.aborted) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
} catch (error) {
if (getErrorStatus(error) === 429) {
throw new acp.RequestError(
429,
'Rate limit exceeded. Try again later.',
);
}
if (
pendingSend.signal.aborted ||
(error instanceof Error && error.name === 'AbortError')
) {
return { stopReason: CoreToolCallStatus.Cancelled };
}
throw new acp.RequestError(
getErrorStatus(error) || 500,
getAcpErrorMessage(error),
);
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(pendingSend.signal, promptId, fc);
toolResponseParts.push(...response);
}
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
return { stopReason: 'end_turn' };
}
private async sendUpdate(
update: acp.SessionNotification['update'],
): Promise<void> {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
};
await this.connection.sessionUpdate(params);
}
private async runTool(
abortSignal: AbortSignal,
promptId: string,
fc: FunctionCall,
): Promise<Part[]> {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const args = fc.args ?? {};
const startTime = Date.now();
const errorResponse = (error: Error) => {
const durationMs = Date.now() - startTime;
logToolCall(
this.config,
new ToolCallEvent(
undefined,
fc.name ?? '',
args,
durationMs,
false,
promptId,
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
error.message,
),
);
return [
{
functionResponse: {
id: callId,
name: fc.name ?? '',
response: { error: error.message },
},
},
];
};
if (!fc.name) {
return errorResponse(new Error('Missing function name'));
}
const toolRegistry = this.config.getToolRegistry();
const tool = toolRegistry.getTool(fc.name);
if (!tool) {
return errorResponse(
new Error(`Tool "${fc.name}" not found in registry.`),
);
}
try {
const invocation = tool.build(args);
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
}
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
},
};
const output = await this.connection.requestPermission(params);
const outcome =
output.outcome.outcome === CoreToolCallStatus.Cancelled
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await confirmationDetails.onConfirm(outcome);
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysAndSave:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
});
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
const durationMs = Date.now() - startTime;
logToolCall(
this.config,
new ToolCallEvent(
undefined,
fc.name ?? '',
args,
durationMs,
true,
promptId,
typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool
? 'mcp'
: 'native',
),
);
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: CoreToolCallStatus.Success,
request: {
callId,
name: fc.name,
args,
isClientInitiated: false,
prompt_id: promptId,
},
tool,
invocation,
response: {
callId,
responseParts: convertToFunctionResponse(
fc.name,
callId,
toolResult.llmContent,
this.config.getActiveModel(),
),
resultDisplay: toolResult.returnDisplay,
error: undefined,
errorType: undefined,
},
},
]);
return convertToFunctionResponse(
fc.name,
callId,
toolResult.llmContent,
this.config.getActiveModel(),
);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
});
this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [
{
status: CoreToolCallStatus.Error,
request: {
callId,
name: fc.name,
args,
isClientInitiated: false,
prompt_id: promptId,
},
tool,
response: {
callId,
responseParts: [
{
functionResponse: {
id: callId,
name: fc.name ?? '',
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
errorType: undefined,
},
},
]);
return errorResponse(error);
}
}
async #resolvePrompt(
message: acp.ContentBlock[],
abortSignal: AbortSignal,
): Promise<Part[]> {
const FILE_URI_SCHEME = 'file://';
const embeddedContext: acp.EmbeddedResourceResource[] = [];
const parts = message.map((part) => {
switch (part.type) {
case 'text':
return { text: part.text };
case 'image':
case 'audio':
return {
inlineData: {
mimeType: part.mimeType,
data: part.data,
},
};
case 'resource_link': {
if (part.uri.startsWith(FILE_URI_SCHEME)) {
return {
fileData: {
mimeData: part.mimeType,
name: part.name,
fileUri: part.uri.slice(FILE_URI_SCHEME.length),
},
};
} else {
return { text: `@${part.uri}` };
}
}
case 'resource': {
embeddedContext.push(part.resource);
return { text: `@${part.resource.uri}` };
}
default: {
const unreachable: never = part;
throw new Error(`Unexpected chunk type: '${unreachable}'`);
}
}
});
const atPathCommandParts = parts.filter((part) => 'fileData' in part);
if (atPathCommandParts.length === 0 && embeddedContext.length === 0) {
return parts;
}
const atPathToResolvedSpecMap = new Map<string, string>();
// Get centralized file discovery service
const fileDiscovery = this.config.getFileService();
const fileFilteringOptions: FilterFilesOptions =
this.config.getFileFilteringOptions();
const pathSpecsToRead: string[] = [];
const contentLabelsForDisplay: string[] = [];
const ignoredPaths: string[] = [];
const toolRegistry = this.config.getToolRegistry();
const readManyFilesTool = new ReadManyFilesTool(
this.config,
this.config.getMessageBus(),
);
const globTool = toolRegistry.getTool('glob');
if (!readManyFilesTool) {
throw new Error('Error: read_many_files tool not found.');
}
for (const atPathPart of atPathCommandParts) {
const pathName = atPathPart.fileData!.fileUri;
// Check if path should be ignored
if (fileDiscovery.shouldIgnoreFile(pathName, fileFilteringOptions)) {
ignoredPaths.push(pathName);
debugLogger.warn(`Path ${pathName} is ignored and will be skipped.`);
continue;
}
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
try {
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
const stats = await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
: `${pathName}/**`;
this.debug(
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
} else {
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
}
resolvedSuccessfully = true;
} else {
this.debug(
`Path ${pathName} is outside the project directory. Skipping.`,
);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (this.config.getEnableRecursiveFileSearch() && globTool) {
this.debug(
`Path ${pathName} not found directly, attempting glob search.`,
);
try {
const globResult = await globTool.buildAndExecute(
{
pattern: `**/*${pathName}*`,
path: this.config.getTargetDir(),
},
abortSignal,
);
if (
globResult.llmContent &&
typeof globResult.llmContent === 'string' &&
!globResult.llmContent.startsWith('No files found') &&
!globResult.llmContent.startsWith('Error:')
) {
const lines = globResult.llmContent.split('\n');
if (lines.length > 1 && lines[1]) {
const firstMatchAbsolute = lines[1].trim();
currentPathSpec = path.relative(
this.config.getTargetDir(),
firstMatchAbsolute,
);
this.debug(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
);
resolvedSuccessfully = true;
} else {
this.debug(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
);
}
} else {
this.debug(
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
} catch (globError) {
debugLogger.error(
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
);
}
} else {
this.debug(
`Glob tool not found. Path ${pathName} will be skipped.`,
);
}
} else {
debugLogger.error(
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
);
}
}
if (resolvedSuccessfully) {
pathSpecsToRead.push(currentPathSpec);
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
contentLabelsForDisplay.push(pathName);
}
}
// Construct the initial part of the query for the LLM
let initialQueryText = '';
for (let i = 0; i < parts.length; i++) {
const chunk = parts[i];
if ('text' in chunk) {
initialQueryText += chunk.text;
} else {
// type === 'atPath'
const resolvedSpec =
chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri);
if (
i > 0 &&
initialQueryText.length > 0 &&
!initialQueryText.endsWith(' ') &&
resolvedSpec
) {
// Add space if previous part was text and didn't end with space, or if previous was @path
const prevPart = parts[i - 1];
if (
'text' in prevPart ||
('fileData' in prevPart &&
atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri))
) {
initialQueryText += ' ';
}
}
if (resolvedSpec) {
initialQueryText += `@${resolvedSpec}`;
} else {
// If not resolved for reading (e.g. lone @ or invalid path that was skipped),
// add the original @-string back, ensuring spacing if it's not the first element.
if (
i > 0 &&
initialQueryText.length > 0 &&
!initialQueryText.endsWith(' ') &&
!chunk.fileData?.fileUri.startsWith(' ')
) {
initialQueryText += ' ';
}
if (chunk.fileData?.fileUri) {
initialQueryText += `@${chunk.fileData.fileUri}`;
}
}
}
}
initialQueryText = initialQueryText.trim();
// Inform user about ignored paths
if (ignoredPaths.length > 0) {
this.debug(
`Ignored ${ignoredPaths.length} files: ${ignoredPaths.join(', ')}`,
);
}
const processedQueryParts: Part[] = [{ text: initialQueryText }];
if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) {
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
debugLogger.warn('No valid file paths found in @ commands to read.');
return [{ text: initialQueryText }];
}
if (pathSpecsToRead.length > 0) {
const toolArgs = {
include: pathSpecsToRead,
};
const callId = `${readManyFilesTool.name}-${Date.now()}`;
try {
const invocation = readManyFilesTool.build(toolArgs);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: toAcpToolKind(readManyFilesTool.kind),
});
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'content',
content: {
type: 'text',
text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
},
};
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: `\n${REFERENCE_CONTENT_START}`,
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
} else {
debugLogger.warn(
'read_many_files tool returned no content or empty content.',
);
}
} catch (error: unknown) {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{
type: 'content',
content: {
type: 'text',
text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
},
},
],
});
throw error;
}
}
if (embeddedContext.length > 0) {
processedQueryParts.push({
text: '\n--- Content from referenced context ---',
});
for (const contextPart of embeddedContext) {
processedQueryParts.push({
text: `\nContent from @${contextPart.uri}:\n`,
});
if ('text' in contextPart) {
processedQueryParts.push({
text: contextPart.text,
});
} else {
processedQueryParts.push({
inlineData: {
mimeType: contextPart.mimeType ?? 'application/octet-stream',
data: contextPart.blob,
},
});
}
}
}
return processedQueryParts;
}
debug(msg: string) {
if (this.config.getDebugMode()) {
debugLogger.warn(msg);
}
}
}
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
if (toolResult.error?.message) {
throw new Error(toolResult.error.message);
}
if (toolResult.returnDisplay) {
if (typeof toolResult.returnDisplay === 'string') {
return {
type: 'content',
content: { type: 'text', text: toolResult.returnDisplay },
};
} else {
if ('fileName' in toolResult.returnDisplay) {
return {
type: 'diff',
path: toolResult.returnDisplay.fileName,
oldText: toolResult.returnDisplay.originalContent,
newText: toolResult.returnDisplay.newContent,
};
}
return null;
}
} else {
return null;
}
}
const basicPermissionOptions = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): acp.PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'ask_user':
// askuser doesn't need "always allow" options since it's asking questions
return [...basicPermissionOptions];
case 'exit_plan_mode':
// exit_plan_mode doesn't need "always allow" options since it's a plan approval flow
return [...basicPermissionOptions];
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
}
/**
* Maps our internal tool kind to the ACP ToolKind.
* Fallback to 'other' for kinds that are not supported by the ACP protocol.
*/
function toAcpToolKind(kind: Kind): acp.ToolKind {
switch (kind) {
case Kind.Read:
case Kind.Edit:
case Kind.Delete:
case Kind.Move:
case Kind.Search:
case Kind.Execute:
case Kind.Think:
case Kind.Fetch:
case Kind.Other:
return kind as acp.ToolKind;
case Kind.Communicate:
default:
return 'other';
}
}