/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type { ReadableStream, WritableStream } from 'node:stream/web'; import type { Content, FunctionCall, Part } from '@google/genai'; import type { Config, GeminiChat, ToolCallConfirmationDetails, ToolResult, SubAgentEventEmitter, SubAgentToolCallEvent, SubAgentToolResultEvent, SubAgentApprovalRequestEvent, AnyDeclarativeTool, AnyToolInvocation, } from '@qwen-code/qwen-code-core'; import { AuthType, clearCachedCredentialFile, convertToFunctionResponse, DiscoveredMCPTool, StreamEventType, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_FLASH_MODEL, MCPServerConfig, ToolConfirmationOutcome, logToolCall, logUserPrompt, getErrorStatus, isWithinRoot, isNodeError, SubAgentEventType, TaskTool, Kind, TodoWriteTool, UserPromptEvent, } from '@qwen-code/qwen-code-core'; import * as acp from './acp.js'; import { AcpFileSystemService } from './fileSystemService.js'; import { Readable, Writable } from 'node:stream'; import type { LoadedSettings } from '../config/settings.js'; import { SettingScope } 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 { getErrorMessage } from '../utils/errors.js'; import { ExtensionStorage, type Extension } from '../config/extension.js'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { ExtensionEnablementManager } from '../config/extensions/extensionEnablement.js'; import { handleSlashCommand, getAvailableCommands, } from '../nonInteractiveCliCommands.js'; import type { AvailableCommand, AvailableCommandsUpdate } from './schema.js'; import { isSlashCommand } from '../ui/utils/commandUtils.js'; /** * Built-in commands that are allowed in ACP integration mode. * Only these commands will be available when using handleSlashCommand * or getAvailableCommands in ACP integration. * * Currently, only "init" is supported because `handleSlashCommand` in * nonInteractiveCliCommands.ts only supports handling results where * result.type is "submit_prompt". Other result types are either coupled * to the UI or cannot send notifications to the client via ACP. * * If you have a good idea to add support for more commands, PRs are welcome! */ const ALLOWED_BUILTIN_COMMANDS_FOR_ACP = ['init']; /** * Resolves the model to use based on the current configuration. * * If the model is set to "auto", it will use the flash model if in fallback * mode, otherwise it will use the default model. */ export function resolveModel(model: string, isInFallbackMode: boolean): string { if (model === DEFAULT_GEMINI_MODEL_AUTO) { return isInFallbackMode ? DEFAULT_GEMINI_FLASH_MODEL : DEFAULT_GEMINI_MODEL; } return model; } export async function runZedIntegration( config: Config, settings: LoadedSettings, extensions: Extension[], argv: CliArgs, ) { const stdout = Writable.toWeb(process.stdout) as WritableStream; const stdin = Readable.toWeb(process.stdin) as ReadableStream; // Stdout is used to send messages to the client, so console.log/console.info // messages to stderr so that they don't interfere with ACP. console.log = console.error; console.info = console.error; console.debug = console.error; new acp.AgentSideConnection( (client: acp.Client) => new GeminiAgent(config, settings, extensions, argv, client), stdout, stdin, ); } class GeminiAgent { private sessions: Map = new Map(); private clientCapabilities: acp.ClientCapabilities | undefined; constructor( private config: Config, private settings: LoadedSettings, private extensions: Extension[], private argv: CliArgs, private client: acp.Client, ) {} async initialize( args: acp.InitializeRequest, ): Promise { this.clientCapabilities = args.clientCapabilities; const authMethods = [ { id: AuthType.USE_OPENAI, name: 'Use OpenAI API key', description: 'Requires setting the `OPENAI_API_KEY` environment variable', }, { id: AuthType.QWEN_OAUTH, name: 'Qwen OAuth', description: 'OAuth authentication for Qwen models with 2000 daily requests', }, ]; return { protocolVersion: acp.PROTOCOL_VERSION, authMethods, agentCapabilities: { loadSession: false, promptCapabilities: { image: true, audio: true, embeddedContext: true, }, }, }; } async authenticate({ methodId }: acp.AuthenticateRequest): Promise { const method = z.nativeEnum(AuthType).parse(methodId); await clearCachedCredentialFile(); await this.config.refreshAuth(method); this.settings.setValue( SettingScope.User, 'security.auth.selectedType', method, ); } async newSession({ cwd, mcpServers, }: acp.NewSessionRequest): Promise { const sessionId = this.config.getSessionId() || randomUUID(); const config = await this.newSessionConfig(sessionId, cwd, mcpServers); let isAuthenticated = false; if (this.settings.merged.security?.auth?.selectedType) { try { await config.refreshAuth( this.settings.merged.security.auth.selectedType, ); isAuthenticated = true; } catch (e) { console.error(`Authentication failed: ${e}`); } } if (!isAuthenticated) { throw acp.RequestError.authRequired(); } if (this.clientCapabilities?.fs) { const acpFileSystemService = new AcpFileSystemService( this.client, sessionId, this.clientCapabilities.fs, config.getFileSystemService(), ); config.setFileSystemService(acpFileSystemService); } const geminiClient = config.getGeminiClient(); const chat = await geminiClient.startChat(); const session = new Session( sessionId, chat, config, this.client, this.settings, ); this.sessions.set(sessionId, session); // Send available commands update as the first session update setTimeout(async () => { await session.sendAvailableCommandsUpdate(); }, 0); return { sessionId, }; } async newSessionConfig( sessionId: string, cwd: string, mcpServers: acp.McpServer[], ): Promise { const mergedMcpServers = { ...this.settings.merged.mcpServers }; for (const { command, args, env: rawEnv, name } of mcpServers) { const env: Record = {}; for (const { name: envName, value } of rawEnv) { env[envName] = value; } mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd); } const settings = { ...this.settings.merged, mcpServers: mergedMcpServers }; const config = await loadCliConfig( settings, this.extensions, new ExtensionEnablementManager( ExtensionStorage.getUserExtensionsDir(), this.argv.extensions, ), sessionId, this.argv, cwd, ); await config.initialize(); return config; } async cancel(params: acp.CancelNotification): Promise { 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 { const session = this.sessions.get(params.sessionId); if (!session) { throw new Error(`Session not found: ${params.sessionId}`); } return session.prompt(params); } } class Session { private pendingPrompt: AbortController | null = null; private turn: number = 0; constructor( private readonly id: string, private readonly chat: GeminiChat, private readonly config: Config, private readonly client: acp.Client, private readonly settings: LoadedSettings, ) {} async cancelPendingPrompt(): Promise { if (!this.pendingPrompt) { throw new Error('Not currently generating'); } this.pendingPrompt.abort(); this.pendingPrompt = null; } async prompt(params: acp.PromptRequest): Promise { this.pendingPrompt?.abort(); const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; // Increment turn counter for each user prompt this.turn += 1; const chat = this.chat; const promptId = this.config.getSessionId() + '########' + this.turn; // Extract text from all text blocks to construct the full prompt text for logging const promptText = params.prompt .filter((block) => block.type === 'text') .map((block) => (block.type === 'text' ? block.text : '')) .join(' '); // Log user prompt logUserPrompt( this.config, new UserPromptEvent( promptText.length, promptId, this.config.getContentGeneratorConfig()?.authType, promptText, ), ); // Check if the input contains a slash command // Extract text from the first text block if present const firstTextBlock = params.prompt.find((block) => block.type === 'text'); const inputText = firstTextBlock?.text || ''; let parts: Part[]; if (isSlashCommand(inputText)) { // Handle slash command - allow specific built-in commands for ACP integration const slashCommandResult = await handleSlashCommand( inputText, pendingSend, this.config, this.settings, ALLOWED_BUILTIN_COMMANDS_FOR_ACP, ); if (slashCommandResult) { // Use the result from the slash command parts = slashCommandResult as Part[]; } else { // Slash command didn't return a prompt, continue with normal processing parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); } } else { // Normal processing for non-slash commands 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: 'cancelled' }; } const functionCalls: FunctionCall[] = []; try { const responseStream = await chat.sendMessageStream( resolveModel(this.config.getModel(), this.config.isInFallbackMode()), { message: nextMessage?.parts ?? [], config: { abortSignal: pendingSend.signal, }, }, promptId, ); nextMessage = null; for await (const resp of responseStream) { if (pendingSend.signal.aborted) { return { stopReason: '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, }; 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); } } } catch (error) { if (getErrorStatus(error) === 429) { throw new acp.RequestError( 429, 'Rate limit exceeded. Try again later.', ); } throw 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.SessionUpdate): Promise { const params: acp.SessionNotification = { sessionId: this.id, update, }; await this.client.sessionUpdate(params); } async sendAvailableCommandsUpdate(): Promise { const abortController = new AbortController(); try { const slashCommands = await getAvailableCommands( this.config, this.settings, abortController.signal, ALLOWED_BUILTIN_COMMANDS_FOR_ACP, ); // Convert SlashCommand[] to AvailableCommand[] format for ACP protocol const availableCommands: AvailableCommand[] = slashCommands.map( (cmd) => ({ name: cmd.name, description: cmd.description, input: null, }), ); const update: AvailableCommandsUpdate = { sessionUpdate: 'available_commands_update', availableCommands, }; await this.sendUpdate(update); } catch (error) { // Log error but don't fail session creation console.error('Error sending available commands update:', error); } } private async runTool( abortSignal: AbortSignal, promptId: string, fc: FunctionCall, ): Promise { const callId = fc.id ?? `${fc.name}-${Date.now()}`; const args = (fc.args ?? {}) as Record; const startTime = Date.now(); const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; logToolCall(this.config, { 'event.name': 'tool_call', 'event.timestamp': new Date().toISOString(), prompt_id: promptId, function_name: fc.name ?? '', function_args: args, duration_ms: durationMs, status: 'error', success: false, error: error.message, tool_type: typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool ? 'mcp' : 'native', }); 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 as string); if (!tool) { return errorResponse( new Error(`Tool "${fc.name}" not found in registry.`), ); } // Detect TodoWriteTool early - route to plan updates instead of tool_call events const isTodoWriteTool = fc.name === TodoWriteTool.Name || tool.name === TodoWriteTool.Name; // Declare subAgentToolEventListeners outside try block for cleanup in catch let subAgentToolEventListeners: Array<() => void> = []; try { const invocation = tool.build(args); // Detect TaskTool and set up sub-agent tool tracking const isTaskTool = tool.name === TaskTool.Name; if (isTaskTool && 'eventEmitter' in invocation) { // Access eventEmitter from TaskTool invocation const taskEventEmitter = ( invocation as { eventEmitter: SubAgentEventEmitter; } ).eventEmitter; // Set up sub-agent tool tracking subAgentToolEventListeners = this.setupSubAgentToolTracking( taskEventEmitter, abortSignal, ); } 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: tool.kind, }, }; const output = await this.client.requestPermission(params); const outcome = output.outcome.outcome === '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.ProceedAlwaysServer: case ToolConfirmationOutcome.ProceedAlwaysTool: case ToolConfirmationOutcome.ModifyWithEditor: break; default: { const resultOutcome: never = outcome; throw new Error(`Unexpected: ${resultOutcome}`); } } } else if (!isTodoWriteTool) { // Skip tool_call event for TodoWriteTool await this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: callId, status: 'in_progress', title: invocation.getDescription(), content: [], locations: invocation.toolLocations(), kind: tool.kind, }); } const toolResult: ToolResult = await invocation.execute(abortSignal); // Clean up event listeners subAgentToolEventListeners.forEach((cleanup) => cleanup()); // Handle TodoWriteTool: extract todos and send plan update if (isTodoWriteTool) { // Extract todos from args (initial state) let todos: Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; }> = []; if (Array.isArray(args['todos'])) { todos = args['todos'] as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; }>; } // If returnDisplay has todos (e.g., modified by user), use those instead if ( toolResult.returnDisplay && typeof toolResult.returnDisplay === 'object' && 'type' in toolResult.returnDisplay && toolResult.returnDisplay.type === 'todo_list' && 'todos' in toolResult.returnDisplay && Array.isArray(toolResult.returnDisplay.todos) ) { todos = toolResult.returnDisplay.todos; } // Convert todos to plan entries and send plan update if (todos.length > 0 || Array.isArray(args['todos'])) { const planEntries = convertTodosToPlanEntries(todos); await this.sendUpdate({ sessionUpdate: 'plan', entries: planEntries, }); } // Skip tool_call_update event for TodoWriteTool // Still log and return function response for LLM } else { // Normal tool handling: send tool_call_update 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, { 'event.name': 'tool_call', 'event.timestamp': new Date().toISOString(), function_name: fc.name, function_args: args, duration_ms: durationMs, status: 'success', success: true, prompt_id: promptId, tool_type: typeof tool !== 'undefined' && tool instanceof DiscoveredMCPTool ? 'mcp' : 'native', }); return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); } catch (e) { // Ensure cleanup on error subAgentToolEventListeners.forEach((cleanup) => cleanup()); 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 } }, ], }); return errorResponse(error); } } /** * Sets up event listeners to track sub-agent tool calls within a TaskTool execution. * Converts subagent tool call events into zedIntegration session updates. * * @param eventEmitter - The SubAgentEventEmitter from TaskTool * @param abortSignal - Signal to abort tracking if parent is cancelled * @returns Array of cleanup functions to remove event listeners */ private setupSubAgentToolTracking( eventEmitter: SubAgentEventEmitter, abortSignal: AbortSignal, ): Array<() => void> { const cleanupFunctions: Array<() => void> = []; const toolRegistry = this.config.getToolRegistry(); // Track subagent tool call states const subAgentToolStates = new Map< string, { tool?: AnyDeclarativeTool; invocation?: AnyToolInvocation; args?: Record; } >(); // Listen for tool call start const onToolCall = (...args: unknown[]) => { const event = args[0] as SubAgentToolCallEvent; if (abortSignal.aborted) return; const subAgentTool = toolRegistry.getTool(event.name); let subAgentInvocation: AnyToolInvocation | undefined; let toolKind: acp.ToolKind = 'other'; let locations: acp.ToolCallLocation[] = []; if (subAgentTool) { try { subAgentInvocation = subAgentTool.build(event.args); toolKind = this.mapToolKind(subAgentTool.kind); locations = subAgentInvocation.toolLocations().map((loc) => ({ path: loc.path, line: loc.line ?? null, })); } catch (e) { // If building fails, continue with defaults console.warn(`Failed to build subagent tool ${event.name}:`, e); } } // Save state for subsequent updates subAgentToolStates.set(event.callId, { tool: subAgentTool, invocation: subAgentInvocation, args: event.args, }); // Check if this is TodoWriteTool - if so, skip sending tool_call event // Plan update will be sent in onToolResult when we have the final state if (event.name === TodoWriteTool.Name) { return; } // Send tool call start update with rawInput void this.sendUpdate({ sessionUpdate: 'tool_call', toolCallId: event.callId, status: 'in_progress', title: event.description || event.name, content: [], locations, kind: toolKind, rawInput: event.args, }); }; // Listen for tool call result const onToolResult = (...args: unknown[]) => { const event = args[0] as SubAgentToolResultEvent; if (abortSignal.aborted) return; const state = subAgentToolStates.get(event.callId); // Check if this is TodoWriteTool - if so, route to plan updates if (event.name === TodoWriteTool.Name) { let todos: | Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; }> | undefined; // Try to extract todos from resultDisplay first (final state) if (event.resultDisplay) { try { // resultDisplay might be a JSON stringified object const parsed = typeof event.resultDisplay === 'string' ? JSON.parse(event.resultDisplay) : event.resultDisplay; if ( typeof parsed === 'object' && parsed !== null && 'type' in parsed && parsed.type === 'todo_list' && 'todos' in parsed && Array.isArray(parsed.todos) ) { todos = parsed.todos; } } catch { // If parsing fails, ignore - resultDisplay might not be JSON } } // Fallback to args if resultDisplay doesn't have todos if (!todos && state?.args && Array.isArray(state.args['todos'])) { todos = state.args['todos'] as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; }>; } // Send plan update if we have todos if (todos) { const planEntries = convertTodosToPlanEntries(todos); void this.sendUpdate({ sessionUpdate: 'plan', entries: planEntries, }); } // Skip sending tool_call_update event for TodoWriteTool // Clean up state subAgentToolStates.delete(event.callId); return; } let content: acp.ToolCallContent[] = []; // If there's a result display, try to convert to ToolCallContent if (event.resultDisplay && state?.invocation) { // resultDisplay is typically a string if (typeof event.resultDisplay === 'string') { content = [ { type: 'content', content: { type: 'text', text: event.resultDisplay, }, }, ]; } } // Send tool call completion update void this.sendUpdate({ sessionUpdate: 'tool_call_update', toolCallId: event.callId, status: event.success ? 'completed' : 'failed', content: content.length > 0 ? content : [], title: state?.invocation?.getDescription() ?? event.name, kind: state?.tool ? this.mapToolKind(state.tool.kind) : null, locations: state?.invocation?.toolLocations().map((loc) => ({ path: loc.path, line: loc.line ?? null, })) ?? null, rawInput: state?.args, }); // Clean up state subAgentToolStates.delete(event.callId); }; // Listen for permission requests const onToolWaitingApproval = async (...args: unknown[]) => { const event = args[0] as SubAgentApprovalRequestEvent; if (abortSignal.aborted) return; const state = subAgentToolStates.get(event.callId); const content: acp.ToolCallContent[] = []; // Handle different confirmation types if (event.confirmationDetails.type === 'edit') { const editDetails = event.confirmationDetails as unknown as { type: 'edit'; fileName: string; originalContent: string | null; newContent: string; }; content.push({ type: 'diff', path: editDetails.fileName, oldText: editDetails.originalContent ?? '', newText: editDetails.newContent, }); } // Build permission request options from confirmation details // event.confirmationDetails already contains all fields except onConfirm, // which we add here to satisfy the type requirement for toPermissionOptions const fullConfirmationDetails = { ...event.confirmationDetails, onConfirm: async () => { // This is a placeholder - the actual response is handled via event.respond }, } as unknown as ToolCallConfirmationDetails; const params: acp.RequestPermissionRequest = { sessionId: this.id, options: toPermissionOptions(fullConfirmationDetails), toolCall: { toolCallId: event.callId, status: 'pending', title: event.description || event.name, content, locations: state?.invocation?.toolLocations().map((loc) => ({ path: loc.path, line: loc.line ?? null, })) ?? [], kind: state?.tool ? this.mapToolKind(state.tool.kind) : 'other', rawInput: state?.args, }, }; try { // Request permission from zed client const output = await this.client.requestPermission(params); const outcome = output.outcome.outcome === 'cancelled' ? ToolConfirmationOutcome.Cancel : z .nativeEnum(ToolConfirmationOutcome) .parse(output.outcome.optionId); // Respond to subagent with the outcome await event.respond(outcome); } catch (error) { // If permission request fails, cancel the tool call console.error( `Permission request failed for subagent tool ${event.name}:`, error, ); await event.respond(ToolConfirmationOutcome.Cancel); } }; // Register event listeners eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.on( SubAgentEventType.TOOL_WAITING_APPROVAL, onToolWaitingApproval, ); // Return cleanup functions cleanupFunctions.push(() => { eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); eventEmitter.off( SubAgentEventType.TOOL_WAITING_APPROVAL, onToolWaitingApproval, ); }); return cleanupFunctions; } /** * Maps core Tool Kind enum to ACP ToolKind string literals. * * @param kind - The core Kind enum value * @returns The corresponding ACP ToolKind string literal */ private mapToolKind(kind: Kind): acp.ToolKind { const kindMap: Record = { [Kind.Read]: 'read', [Kind.Edit]: 'edit', [Kind.Delete]: 'delete', [Kind.Move]: 'move', [Kind.Search]: 'search', [Kind.Execute]: 'execute', [Kind.Think]: 'think', [Kind.Fetch]: 'fetch', [Kind.Other]: 'other', }; return kindMap[kind] ?? 'other'; } async #resolvePrompt( message: acp.ContentBlock[], abortSignal: AbortSignal, ): Promise { 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(); // Get centralized file discovery service const fileDiscovery = this.config.getFileService(); const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore(); const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; const toolRegistry = this.config.getToolRegistry(); const readManyFilesTool = toolRegistry.getTool('read_many_files'); 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 by git if (fileDiscovery.shouldGitIgnoreFile(pathName)) { ignoredPaths.push(pathName); const reason = respectGitIgnore ? 'git-ignored and will be skipped' : 'ignored by custom patterns'; console.warn(`Path ${pathName} is ${reason}.`); 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) { console.error( `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, ); } } else { this.debug( `Glob tool not found. Path ${pathName} will be skipped.`, ); } } else { console.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) { const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored'; this.debug( `Ignored ${ignoredPaths.length} ${ignoreType} 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 console.warn('No valid file paths found in @ commands to read.'); return [{ text: initialQueryText }]; } if (pathSpecsToRead.length > 0) { const toolArgs = { paths: pathSpecsToRead, respectGitIgnore, // Use configuration setting }; 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: 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--- Content from referenced files ---', }); 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 { console.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()) { console.warn(msg); } } } /** * Converts todo items to plan entries format for zed integration. * Maps todo status to plan status and assigns a default priority. * * @param todos - Array of todo items with id, content, and status * @returns Array of plan entries with content, priority, and status */ function convertTodosToPlanEntries( todos: Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed'; }>, ): acp.PlanEntry[] { return todos.map((todo) => ({ content: todo.content, priority: 'medium' as const, // Default priority since todos don't have priority status: todo.status, })); } 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 ( 'type' in toolResult.returnDisplay && toolResult.returnDisplay.type === 'plan_summary' ) { const planDisplay = toolResult.returnDisplay; const planText = `${planDisplay.message}\n\n${planDisplay.plan}`; return { type: 'content', content: { type: 'text', text: planText }, }; } else { if ('fileName' in toolResult.returnDisplay) { return { type: 'diff', path: toolResult.returnDisplay.fileName, oldText: toolResult.returnDisplay.originalContent, newText: toolResult.returnDisplay.newContent, }; } return null; } } 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 'plan': return [ { optionId: ToolConfirmationOutcome.ProceedAlways, name: `Always Allow Plans`, kind: 'allow_always', }, ...basicPermissionOptions, ]; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); } } }