diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/acp/acp.ts | 464 | ||||
| -rw-r--r-- | packages/cli/src/acp/acpPeer.ts | 674 | ||||
| -rw-r--r-- | packages/cli/src/config/config.ts | 6 | ||||
| -rw-r--r-- | packages/cli/src/gemini.tsx | 5 | ||||
| -rw-r--r-- | packages/cli/src/ui/components/messages/ToolMessage.test.tsx | 2 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useToolScheduler.test.ts | 11 |
6 files changed, 1161 insertions, 1 deletions
diff --git a/packages/cli/src/acp/acp.ts b/packages/cli/src/acp/acp.ts new file mode 100644 index 00000000..1fbdf7a8 --- /dev/null +++ b/packages/cli/src/acp/acp.ts @@ -0,0 +1,464 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ + +import { Icon } from '@google/gemini-cli-core'; +import { WritableStream, ReadableStream } from 'node:stream/web'; + +export class ClientConnection implements Client { + #connection: Connection<Agent>; + + constructor( + agent: (client: Client) => Agent, + input: WritableStream<Uint8Array>, + output: ReadableStream<Uint8Array>, + ) { + this.#connection = new Connection(agent(this), input, output); + } + + /** + * Streams part of an assistant response to the client + */ + async streamAssistantMessageChunk( + params: StreamAssistantMessageChunkParams, + ): Promise<void> { + await this.#connection.sendRequest('streamAssistantMessageChunk', params); + } + + /** + * Request confirmation before running a tool + * + * When allowed, the client returns a [`ToolCallId`] which can be used + * to update the tool call's `status` and `content` as it runs. + */ + requestToolCallConfirmation( + params: RequestToolCallConfirmationParams, + ): Promise<RequestToolCallConfirmationResponse> { + return this.#connection.sendRequest('requestToolCallConfirmation', params); + } + + /** + * pushToolCall allows the agent to start a tool call + * when it does not need to request permission to do so. + * + * The returned id can be used to update the UI for the tool + * call as needed. + */ + pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse> { + return this.#connection.sendRequest('pushToolCall', params); + } + + /** + * updateToolCall allows the agent to update the content and status of the tool call. + * + * The new content replaces what is currently displayed in the UI. + * + * The [`ToolCallId`] is included in the response of + * `pushToolCall` or `requestToolCallConfirmation` respectively. + */ + async updateToolCall(params: UpdateToolCallParams): Promise<void> { + await this.#connection.sendRequest('updateToolCall', params); + } +} + +type AnyMessage = AnyRequest | AnyResponse; + +type AnyRequest = { + id: number; + method: string; + params?: unknown; +}; + +type AnyResponse = { jsonrpc: '2.0'; id: number } & Result<unknown>; + +type Result<T> = + | { + result: T; + } + | { + error: ErrorResponse; + }; + +type ErrorResponse = { + code: number; + message: string; + data?: { details?: string }; +}; + +type PendingResponse = { + resolve: (response: unknown) => void; + reject: (error: ErrorResponse) => void; +}; + +class Connection<D> { + #pendingResponses: Map<number, PendingResponse> = new Map(); + #nextRequestId: number = 0; + #delegate: D; + #peerInput: WritableStream<Uint8Array>; + #writeQueue: Promise<void> = Promise.resolve(); + #textEncoder: TextEncoder; + + constructor( + delegate: D, + peerInput: WritableStream<Uint8Array>, + peerOutput: ReadableStream<Uint8Array>, + ) { + this.#peerInput = peerInput; + this.#textEncoder = new TextEncoder(); + + this.#delegate = delegate; + this.#receive(peerOutput); + } + + async #receive(output: ReadableStream<Uint8Array>) { + let content = ''; + const decoder = new TextDecoder(); + for await (const chunk of output) { + content += decoder.decode(chunk, { stream: true }); + const lines = content.split('\n'); + content = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine) { + const message = JSON.parse(trimmedLine); + this.#processMessage(message); + } + } + } + } + + async #processMessage(message: AnyMessage) { + if ('method' in message) { + const response = await this.#tryCallDelegateMethod( + message.method, + message.params, + ); + + await this.#sendMessage({ + jsonrpc: '2.0', + id: message.id, + ...response, + }); + } else { + this.#handleResponse(message); + } + } + + async #tryCallDelegateMethod( + method: string, + params?: unknown, + ): Promise<Result<unknown>> { + const methodName = method as keyof D; + if (typeof this.#delegate[methodName] !== 'function') { + return RequestError.methodNotFound(method).toResult(); + } + + try { + const result = await this.#delegate[methodName](params); + return { result: result ?? null }; + } catch (error: unknown) { + if (error instanceof RequestError) { + return error.toResult(); + } + + let details; + + if (error instanceof Error) { + details = error.message; + } else if ( + typeof error === 'object' && + error != null && + 'message' in error && + typeof error.message === 'string' + ) { + details = error.message; + } + + return RequestError.internalError(details).toResult(); + } + } + + #handleResponse(response: AnyResponse) { + const pendingResponse = this.#pendingResponses.get(response.id); + if (pendingResponse) { + if ('result' in response) { + pendingResponse.resolve(response.result); + } else if ('error' in response) { + pendingResponse.reject(response.error); + } + this.#pendingResponses.delete(response.id); + } + } + + async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> { + const id = this.#nextRequestId++; + const responsePromise = new Promise((resolve, reject) => { + this.#pendingResponses.set(id, { resolve, reject }); + }); + await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); + return responsePromise as Promise<Resp>; + } + + async #sendMessage(json: AnyMessage) { + const content = JSON.stringify(json) + '\n'; + this.#writeQueue = this.#writeQueue + .then(async () => { + const writer = this.#peerInput.getWriter(); + try { + await writer.write(this.#textEncoder.encode(content)); + } finally { + writer.releaseLock(); + } + }) + .catch((error) => { + // Continue processing writes on error + console.error('ACP write error:', error); + }); + return this.#writeQueue; + } +} + +export class RequestError extends Error { + data?: { details?: string }; + + constructor( + public code: number, + message: string, + details?: string, + ) { + super(message); + this.name = 'RequestError'; + if (details) { + this.data = { details }; + } + } + + static parseError(details?: string): RequestError { + return new RequestError(-32700, 'Parse error', details); + } + + static invalidRequest(details?: string): RequestError { + return new RequestError(-32600, 'Invalid request', details); + } + + static methodNotFound(details?: string): RequestError { + return new RequestError(-32601, 'Method not found', details); + } + + static invalidParams(details?: string): RequestError { + return new RequestError(-32602, 'Invalid params', details); + } + + static internalError(details?: string): RequestError { + return new RequestError(-32603, 'Internal error', details); + } + + toResult<T>(): Result<T> { + return { + error: { + code: this.code, + message: this.message, + data: this.data, + }, + }; + } +} + +// Protocol types + +export const LATEST_PROTOCOL_VERSION = '0.0.9'; + +export type AssistantMessageChunk = + | { + text: string; + } + | { + thought: string; + }; + +export type ToolCallConfirmation = + | { + description?: string | null; + type: 'edit'; + } + | { + description?: string | null; + type: 'execute'; + command: string; + rootCommand: string; + } + | { + description?: string | null; + type: 'mcp'; + serverName: string; + toolDisplayName: string; + toolName: string; + } + | { + description?: string | null; + type: 'fetch'; + urls: string[]; + } + | { + description: string; + type: 'other'; + }; + +export type ToolCallContent = + | { + type: 'markdown'; + markdown: string; + } + | { + type: 'diff'; + newText: string; + oldText: string | null; + path: string; + }; + +export type ToolCallStatus = 'running' | 'finished' | 'error'; + +export type ToolCallId = number; + +export type ToolCallConfirmationOutcome = + | 'allow' + | 'alwaysAllow' + | 'alwaysAllowMcpServer' + | 'alwaysAllowTool' + | 'reject' + | 'cancel'; + +/** + * A part in a user message + */ +export type UserMessageChunk = + | { + text: string; + } + | { + path: string; + }; + +export interface StreamAssistantMessageChunkParams { + chunk: AssistantMessageChunk; +} + +export interface RequestToolCallConfirmationParams { + confirmation: ToolCallConfirmation; + content?: ToolCallContent | null; + icon: Icon; + label: string; + locations?: ToolCallLocation[]; +} + +export interface ToolCallLocation { + line?: number | null; + path: string; +} + +export interface PushToolCallParams { + content?: ToolCallContent | null; + icon: Icon; + label: string; + locations?: ToolCallLocation[]; +} + +export interface UpdateToolCallParams { + content: ToolCallContent | null; + status: ToolCallStatus; + toolCallId: ToolCallId; +} + +export interface RequestToolCallConfirmationResponse { + id: ToolCallId; + outcome: ToolCallConfirmationOutcome; +} + +export interface PushToolCallResponse { + id: ToolCallId; +} + +export interface InitializeParams { + /** + * The version of the protocol that the client supports. + * This should be the latest version supported by the client. + */ + protocolVersion: string; +} + +export interface SendUserMessageParams { + chunks: UserMessageChunk[]; +} + +export interface InitializeResponse { + /** + * Indicates whether the agent is authenticated and + * ready to handle requests. + */ + isAuthenticated: boolean; + /** + * The version of the protocol that the agent supports. + * If the agent supports the requested version, it should respond with the same version. + * Otherwise, the agent should respond with the latest version it supports. + */ + protocolVersion: string; +} + +export interface Error { + code: number; + data?: unknown; + message: string; +} + +export interface Client { + streamAssistantMessageChunk( + params: StreamAssistantMessageChunkParams, + ): Promise<void>; + + requestToolCallConfirmation( + params: RequestToolCallConfirmationParams, + ): Promise<RequestToolCallConfirmationResponse>; + + pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse>; + + updateToolCall(params: UpdateToolCallParams): Promise<void>; +} + +export interface Agent { + /** + * Initializes the agent's state. It should be called before any other method, + * and no other methods should be called until it has completed. + * + * If the agent is not authenticated, then the client should prompt the user to authenticate, + * and then call the `authenticate` method. + * Otherwise the client can send other messages to the agent. + */ + initialize(params: InitializeParams): Promise<InitializeResponse>; + + /** + * Begins the authentication process. + * + * This method should only be called if `initialize` indicates the user isn't already authenticated. + * The Promise MUST not resolve until authentication is complete. + */ + authenticate(): Promise<void>; + + /** + * Allows the user to send a message to the agent. + * This method should complete after the agent is finished, during + * which time the agent may update the client by calling + * streamAssistantMessageChunk and other methods. + */ + sendUserMessage(params: SendUserMessageParams): Promise<void>; + + /** + * Cancels the current generation. + */ + cancelSendMessage(): Promise<void>; +} diff --git a/packages/cli/src/acp/acpPeer.ts b/packages/cli/src/acp/acpPeer.ts new file mode 100644 index 00000000..90952b7f --- /dev/null +++ b/packages/cli/src/acp/acpPeer.ts @@ -0,0 +1,674 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WritableStream, ReadableStream } from 'node:stream/web'; + +import { + AuthType, + Config, + GeminiChat, + ToolRegistry, + logToolCall, + ToolResult, + convertToFunctionResponse, + ToolCallConfirmationDetails, + ToolConfirmationOutcome, + clearCachedCredentialFile, + isNodeError, + getErrorMessage, + isWithinRoot, + getErrorStatus, +} from '@google/gemini-cli-core'; +import * as acp from './acp.js'; +import { Agent } from './acp.js'; +import { Readable, Writable } from 'node:stream'; +import { Content, Part, FunctionCall, PartListUnion } from '@google/genai'; +import { LoadedSettings, SettingScope } from '../config/settings.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export async function runAcpPeer(config: Config, settings: LoadedSettings) { + const stdout = Writable.toWeb(process.stdout) as WritableStream; + const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>; + + // 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.ClientConnection( + (client: acp.Client) => new GeminiAgent(config, settings, client), + stdout, + stdin, + ); +} + +class GeminiAgent implements Agent { + chat?: GeminiChat; + pendingSend?: AbortController; + + constructor( + private config: Config, + private settings: LoadedSettings, + private client: acp.Client, + ) {} + + async initialize(_: acp.InitializeParams): Promise<acp.InitializeResponse> { + let isAuthenticated = false; + if (this.settings.merged.selectedAuthType) { + try { + await this.config.refreshAuth(this.settings.merged.selectedAuthType); + isAuthenticated = true; + } catch (error) { + console.error('Failed to refresh auth:', error); + } + } + return { protocolVersion: acp.LATEST_PROTOCOL_VERSION, isAuthenticated }; + } + + async authenticate(): Promise<void> { + await clearCachedCredentialFile(); + await this.config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + this.settings.setValue( + SettingScope.User, + 'selectedAuthType', + AuthType.LOGIN_WITH_GOOGLE, + ); + } + + async cancelSendMessage(): Promise<void> { + if (!this.pendingSend) { + throw new Error('Not currently generating'); + } + + this.pendingSend.abort(); + delete this.pendingSend; + } + + async sendUserMessage(params: acp.SendUserMessageParams): Promise<void> { + this.pendingSend?.abort(); + const pendingSend = new AbortController(); + this.pendingSend = pendingSend; + + if (!this.chat) { + const geminiClient = this.config.getGeminiClient(); + this.chat = await geminiClient.startChat(); + } + + const promptId = Math.random().toString(16).slice(2); + const chat = this.chat!; + const toolRegistry: ToolRegistry = await this.config.getToolRegistry(); + const parts = await this.#resolveUserMessage(params, pendingSend.signal); + + let nextMessage: Content | null = { role: 'user', parts }; + + while (nextMessage !== null) { + if (pendingSend.signal.aborted) { + chat.addHistory(nextMessage); + return; + } + + const functionCalls: FunctionCall[] = []; + + try { + const responseStream = await chat.sendMessageStream( + { + message: nextMessage?.parts ?? [], + config: { + abortSignal: pendingSend.signal, + tools: [ + { + functionDeclarations: toolRegistry.getFunctionDeclarations(), + }, + ], + }, + }, + promptId, + ); + nextMessage = null; + + for await (const resp of responseStream) { + if (pendingSend.signal.aborted) { + return; + } + + if (resp.candidates && resp.candidates.length > 0) { + const candidate = resp.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) { + continue; + } + + this.client.streamAssistantMessageChunk({ + chunk: part.thought + ? { thought: part.text } + : { text: part.text }, + }); + } + } + + if (resp.functionCalls) { + functionCalls.push(...resp.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, + ); + + const parts = Array.isArray(response) ? response : [response]; + + for (const part of parts) { + if (typeof part === 'string') { + toolResponseParts.push({ text: part }); + } else if (part) { + toolResponseParts.push(part); + } + } + } + + nextMessage = { role: 'user', parts: toolResponseParts }; + } + } + } + + async #runTool( + abortSignal: AbortSignal, + promptId: string, + fc: FunctionCall, + ): Promise<PartListUnion> { + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + const args = (fc.args ?? {}) as Record<string, unknown>; + + 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, + success: false, + error: 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: ToolRegistry = await this.config.getToolRegistry(); + const tool = toolRegistry.getTool(fc.name as string); + + if (!tool) { + return errorResponse( + new Error(`Tool "${fc.name}" not found in registry.`), + ); + } + + let toolCallId; + const confirmationDetails = await tool.shouldConfirmExecute( + args, + abortSignal, + ); + if (confirmationDetails) { + let content: acp.ToolCallContent | null = null; + if (confirmationDetails.type === 'edit') { + content = { + type: 'diff', + path: confirmationDetails.fileName, + oldText: confirmationDetails.originalContent, + newText: confirmationDetails.newContent, + }; + } + + const result = await this.client.requestToolCallConfirmation({ + label: tool.getDescription(args), + icon: tool.icon, + content, + confirmation: toAcpToolCallConfirmation(confirmationDetails), + locations: tool.toolLocations(args), + }); + + await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome)); + switch (result.outcome) { + case 'reject': + return errorResponse( + new Error(`Tool "${fc.name}" not allowed to run by the user.`), + ); + + case 'cancel': + return errorResponse( + new Error(`Tool "${fc.name}" was canceled by the user.`), + ); + case 'allow': + case 'alwaysAllow': + case 'alwaysAllowMcpServer': + case 'alwaysAllowTool': + break; + default: { + const resultOutcome: never = result.outcome; + throw new Error(`Unexpected: ${resultOutcome}`); + } + } + + toolCallId = result.id; + } else { + const result = await this.client.pushToolCall({ + icon: tool.icon, + label: tool.getDescription(args), + locations: tool.toolLocations(args), + }); + + toolCallId = result.id; + } + + try { + const toolResult: ToolResult = await tool.execute(args, abortSignal); + const toolCallContent = toToolCallContent(toolResult); + + await this.client.updateToolCall({ + toolCallId, + status: 'finished', + content: toolCallContent, + }); + + 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, + success: true, + prompt_id: promptId, + }); + + return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + await this.client.updateToolCall({ + toolCallId, + status: 'error', + content: { type: 'markdown', markdown: error.message }, + }); + + return errorResponse(error); + } + } + + async #resolveUserMessage( + message: acp.SendUserMessageParams, + abortSignal: AbortSignal, + ): Promise<Part[]> { + const atPathCommandParts = message.chunks.filter((part) => 'path' in part); + + if (atPathCommandParts.length === 0) { + return message.chunks.map((chunk) => { + if ('text' in chunk) { + return { text: chunk.text }; + } else { + throw new Error('Unexpected chunk type'); + } + }); + } + + // Get centralized file discovery service + const fileDiscovery = this.config.getFileService(); + const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore(); + + const pathSpecsToRead: string[] = []; + const atPathToResolvedSpecMap = new Map<string, string>(); + const contentLabelsForDisplay: string[] = []; + const ignoredPaths: string[] = []; + + const toolRegistry = await 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.path; + + // 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.execute( + { + 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 < message.chunks.length; i++) { + const chunk = message.chunks[i]; + if ('text' in chunk) { + initialQueryText += chunk.text; + } else { + // type === 'atPath' + const resolvedSpec = atPathToResolvedSpecMap.get(chunk.path); + 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 = message.chunks[i - 1]; + if ( + 'text' in prevPart || + ('path' in prevPart && atPathToResolvedSpecMap.has(prevPart.path)) + ) { + 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.path.startsWith(' ') + ) { + initialQueryText += ' '; + } + initialQueryText += `@${chunk.path}`; + } + } + } + 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(', ')}`, + ); + } + + // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText + if (pathSpecsToRead.length === 0) { + console.warn('No valid file paths found in @ commands to read.'); + return [{ text: initialQueryText }]; + } + + const processedQueryParts: Part[] = [{ text: initialQueryText }]; + + const toolArgs = { + paths: pathSpecsToRead, + respectGitIgnore, // Use configuration setting + }; + + const toolCall = await this.client.pushToolCall({ + icon: readManyFilesTool.icon, + label: readManyFilesTool.getDescription(toolArgs), + }); + try { + const result = await readManyFilesTool.execute(toolArgs, abortSignal); + const content = toToolCallContent(result) || { + type: 'markdown', + markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, + }; + await this.client.updateToolCall({ + toolCallId: toolCall.id, + status: 'finished', + 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); + } + } + processedQueryParts.push({ text: '\n--- End of content ---' }); + } else { + console.warn( + 'read_many_files tool returned no content or empty content.', + ); + } + + return processedQueryParts; + } catch (error: unknown) { + await this.client.updateToolCall({ + toolCallId: toolCall.id, + status: 'error', + content: { + type: 'markdown', + markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, + }, + }); + throw error; + } + } + + #debug(msg: string) { + if (this.config.getDebugMode()) { + console.warn(msg); + } + } +} + +function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { + if (toolResult.returnDisplay) { + if (typeof toolResult.returnDisplay === 'string') { + return { + type: 'markdown', + markdown: toolResult.returnDisplay, + }; + } else { + return { + type: 'diff', + path: toolResult.returnDisplay.fileName, + oldText: toolResult.returnDisplay.originalContent, + newText: toolResult.returnDisplay.newContent, + }; + } + } else { + return null; + } +} + +function toAcpToolCallConfirmation( + confirmationDetails: ToolCallConfirmationDetails, +): acp.ToolCallConfirmation { + switch (confirmationDetails.type) { + case 'edit': + return { type: 'edit' }; + case 'exec': + return { + type: 'execute', + rootCommand: confirmationDetails.rootCommand, + command: confirmationDetails.command, + }; + case 'mcp': + return { + type: 'mcp', + serverName: confirmationDetails.serverName, + toolName: confirmationDetails.toolName, + toolDisplayName: confirmationDetails.toolDisplayName, + }; + case 'info': + return { + type: 'fetch', + urls: confirmationDetails.urls || [], + description: confirmationDetails.urls?.length + ? null + : confirmationDetails.prompt, + }; + default: { + const unreachable: never = confirmationDetails; + throw new Error(`Unexpected: ${unreachable}`); + } + } +} + +function toToolCallOutcome( + outcome: acp.ToolCallConfirmationOutcome, +): ToolConfirmationOutcome { + switch (outcome) { + case 'allow': + return ToolConfirmationOutcome.ProceedOnce; + case 'alwaysAllow': + return ToolConfirmationOutcome.ProceedAlways; + case 'alwaysAllowMcpServer': + return ToolConfirmationOutcome.ProceedAlwaysServer; + case 'alwaysAllowTool': + return ToolConfirmationOutcome.ProceedAlwaysTool; + case 'reject': + case 'cancel': + return ToolConfirmationOutcome.Cancel; + default: { + const unreachable: never = outcome; + throw new Error(`Unexpected: ${unreachable}`); + } + } +} diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f76d6c60..543801f0 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -54,6 +54,7 @@ export interface CliArgs { telemetryOtlpEndpoint: string | undefined; telemetryLogPrompts: boolean | undefined; allowedMcpServerNames: string[] | undefined; + experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; ideMode: boolean | undefined; @@ -162,6 +163,10 @@ export async function parseArguments(): Promise<CliArgs> { description: 'Enables checkpointing of file edits', default: false, }) + .option('experimental-acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', + }) .option('allowed-mcp-server-names', { type: 'array', string: true, @@ -396,6 +401,7 @@ export async function loadCliConfig( model: argv.model!, extensionContextFilePaths, maxSessionTurns: settings.maxSessionTurns ?? -1, + experimentalAcp: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, activeExtensions: activeExtensions.map((e) => ({ name: e.config.name, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index f0d3f401..71e69952 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -84,6 +84,7 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) { await new Promise((resolve) => child.on('close', resolve)); process.exit(0); } +import { runAcpPeer } from './acp/acpPeer.js'; export async function main() { const workspaceRoot = process.cwd(); @@ -189,6 +190,10 @@ export async function main() { await getOauthClient(settings.merged.selectedAuthType, config); } + if (config.getExperimentalAcp()) { + return runAcpPeer(config, settings); + } + let input = config.getQuestion(); const startupWarnings = [ ...(await getStartupWarnings()), diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 7b9de92e..c9bed003 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -152,6 +152,8 @@ describe('<ToolMessage />', () => { const diffResult = { fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new', fileName: 'file.txt', + originalContent: 'old', + newContent: 'new', }; const { lastFrame } = renderWithContext( <ToolMessage {...baseProps} resultDisplay={diffResult} />, diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index e9354ee9..81ea1f77 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -23,7 +23,8 @@ import { ToolCallResponseInfo, ToolCall, // Import from core Status as ToolCallStatusType, - ApprovalMode, // Import from core + ApprovalMode, + Icon, } from '@google/gemini-cli-core'; import { HistoryItemWithoutId, @@ -56,6 +57,8 @@ const mockTool: Tool = { name: 'mockTool', displayName: 'Mock Tool', description: 'A mock tool for testing', + icon: Icon.Hammer, + toolLocations: vi.fn(), isOutputMarkdown: false, canUpdateOutput: false, schema: {}, @@ -85,6 +88,8 @@ const mockToolRequiresConfirmation: Tool = { onConfirm: mockOnUserConfirmForToolConfirmation, fileName: 'mockToolRequiresConfirmation.ts', fileDiff: 'Mock tool requires confirmation', + originalContent: 'Original content', + newContent: 'New content', }), ), }; @@ -807,6 +812,8 @@ describe('mapToDisplay', () => { isOutputMarkdown: false, canUpdateOutput: false, schema: {}, + icon: Icon.Hammer, + toolLocations: vi.fn(), validateToolParams: vi.fn(), execute: vi.fn(), shouldConfirmExecute: vi.fn(), @@ -885,6 +892,8 @@ describe('mapToDisplay', () => { toolDisplayName: 'Test Tool Display', fileName: 'test.ts', fileDiff: 'Test diff', + originalContent: 'Original content', + newContent: 'New content', } as ToolCallConfirmationDetails, }, expectedStatus: ToolCallStatus.Confirming, |
