chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 11:58:09 +08:00
commit c48e26cdd0
2909 changed files with 1591131 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`)
Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities
over HTTP for inter-agent communication.
## Architecture
- `src/agent/`: Agent session management for A2A interactions.
- `src/commands/`: CLI command definitions for the A2A server binary.
- `src/config/`: Server configuration.
- `src/http/`: HTTP server and route handlers.
- `src/persistence/`: Session and state persistence.
- `src/utils/`: Shared utility functions.
- `src/types.ts`: Shared type definitions.
## Running
- Binary entry point: `gemini-cli-a2a-server`
## Testing
- Run tests: `npm test -w @google/gemini-cli-a2a-server`
+5
View File
@@ -0,0 +1,5 @@
# Gemini CLI A2A Server
## All code in this package is experimental and under active development
This package contains the A2A server implementation for the Gemini CLI.
@@ -0,0 +1,509 @@
# RFC: Gemini CLI A2A Development-Tool Extension
## 1. Introduction
### 1.1 Overview
To standardize client integrations with the Gemini CLI agent, this document
proposes the `development-tool` extension for the A2A protocol.
Rather than creating a new protocol, this specification builds upon the existing
A2A protocol. As an open-source standard recently adopted by the Linux
Foundation, A2A provides a robust foundation for core concepts like tasks,
messages, and streaming events. This extension-based approach allows us to
leverage A2A's proven architecture while defining the specific capabilities
required for rich, interactive workflows with the Gemini CLI agent.
### 1.2 Motivation
Recent work integrating Gemini CLI with clients like Zed and Gemini Code
Assists agent mode has highlighted the need for a robust, standard
communication protocol. Standardizing on A2A provides several key advantages:
- **Solid Foundation**: Provides a robust, open standard that ensures a stable,
predictable, and consistent integration experience across different IDEs and
client surfaces.
- **Extensibility**: Creates a flexible foundation to support new tools and
workflows as they emerge.
- **Ecosystem Alignment**: Aligns Gemini CLI with a growing industry standard,
fostering broader interoperability.
## 2. Communication Flow
The interaction follows A2As task-based, streaming pattern. The client sends a
`message/stream` request and the agent responds with a `contextId` / `taskId`
and a stream of events. `TaskStatusUpdateEvent` events are used to convey the
overall state of the task. The task is complete when the agent sends a final
`TaskStatusUpdateEvent` with `final: true` and a terminal status like
`completed` or `failed`.
### 2.1 Asynchronous Responses and Notifications
Clients that may disconnect from the agent should supply a
`PushNotificationConfig` to the agent with the initial `message/stream` method
or subsequently with the `tasks/pushNotificationConfig/set` method so that the
agent can call back when updates are ready.
## 3. The `development-tool` extension
### 3.1 Overview
The `development-tool` extension establishes a communication contract for
workflows between a client and the Gemini CLI agent. It consists of a
specialized set of schemas, embedded within core A2A data structures, that
enable the agent to stream real-time updates on its state and thought process.
These schemas also provide the mechanism for the agent to request user
permission before executing tools.
**Sample Agent Card**
```json
{
"name": "Gemini CLI Agent",
"description": "An agent that generates code based on natural language instructions.",
"capabilities": {
"streaming": true,
"extensions": [
{
"uri": "https://github.com/google-gemini/gemini-cli/blob/main/docs/a2a/developer-profile/v0/spec.md",
"description": "An extension for interactive development tasks, enabling features like code generation, tool usage, and real-time status updates.",
"required": true
}
]
}
}
```
**Versioning**
The agent card `uri` field contains an embedded semantic version. The client
must extract this version to determine compatibility with the agent extension
using the compatibility logic defined in Semantic Versioning 2.0.0 spec.
### 3.2 Schema Definitions
This section defines the schemas for the `development-tool` A2A extension,
organized by their function within the communication flow. Note that all custom
objects included in the `metadata` field (e.g. `Message.metadata`) must be keyed
by the unique URI that points to that extensions spec to prevent naming
collisions with other extensions.
**Initialization & Configuration**
The first message in a session must contain an `AgentSettings` object in its
metadata. This object provides the agent with the necessary configuration
information for proper initialization. Additional configuration settings (ex.
MCP servers, allowed tools, etc.) can be added to this message.
**Schema**
```proto
syntax = "proto3";
// Configuration settings for the Gemini CLI agent.
message AgentSettings {
// The absolute path to the workspace directory where the agent will execute.
string workspace_path = 1;
}
```
**Agent-to-Client Messages**
All real-time updates from the agent (including its thoughts, tool calls, and
simple text replies) are streamed to the client as `TaskStatusUpdateEvents`.
Each Event contains a `Message` object, which holds the content in one of two
formats:
- **TextPart**: Used for standard text messages. This part requires no custom
schema.
- **DataPart**: Used for complex, structured objects. Tool Calls and Thoughts
are sent this way, each using their respective schemas defined below.
**Tool Calls**
The `ToolCall` schema is designed to provide a structured representation of a
tools execution lifecycle. This protocol defines a clear state machine and
provides detailed schemas for common development tasks (file edits, shell
commands, MCP Tool), ensuring clients can build reliable UIs without being tied
to a specific agent implementation.
The core principle is that the agent sends a `ToolCall` object on every update.
This makes client-side logic stateless and simple.
**Tool Call Lifecycle**
1. **Creation**: The agent sends a `ToolCall` object with `status: PENDING`. If
user permission is required, the `confirmation_request` field will be
populated.
2. **Confirmation**: If the client needs to confirm the message, the client
will send a `ToolCallConfirmation`. If the client responds with a
cancellation, execution will be skipped.
3. **Execution**: Once approved (or if no approval is required), the agent
sends an update with `status: EXECUTING`. It can stream real-time progress
by updating the `live_content` field.
4. **Completion**: The agent sends a final update with the status set to
`SUCCEEDED`, `FAILED`, or `CANCELLED` and populates the appropriate result
field.
**Schema**
```proto
syntax = "proto3";
import "google/protobuf/struct.proto";
// ToolCall is the central message representing a tool's execution lifecycle.
// The entire object is sent from the agent to client on every update.
message ToolCall {
// A unique identifier, assigned by the agent
string tool_call_id = 1;
// The current state of the tool call in its lifecycle
ToolCallStatus status = 2;
// Name of the tool being called (e.g. 'Edit', 'ShellTool')
string tool_name = 3;
// An optional description of the tool call's purpose to show the user
optional string description = 4;
// The structured input params provided by the LLM for tool invocation.
google.protobuf.Struct input_parameters = 5;
// String containing the real-time output from the tool as it executes (primarily designed for shell output).
// During streaming the entire string is replaced on each update
optional string live_content = 6;
// The final result of the tool (used to replace live_content when applicable)
oneof result {
// The output on tool success
ToolOutput output = 7;
// The error details if the tool failed
ErrorDetails error = 8;
}
// If the tool requires user confirmation, this field will be populated while status is PENDING
optional ConfirmationRequest confirmation_request = 9;
}
// Possible execution status of a ToolCall
enum ToolCallStatus {
STATUS_UNSPECIFIED = 0;
PENDING = 1;
EXECUTING = 2;
SUCCEEDED = 3;
FAILED = 4;
CANCELLED = 5;
}
// ToolOutput represents the final, successful, output of a tool
message ToolOutput {
oneof result {
string text = 1;
// For ToolCalls which resulted in a file modification
FileDiff diff = 2;
// A generic fallback for any other structured JSON data
google.protobuf.Struct structured_data = 3;
}
}
// A structured representation of an error
message ErrorDetails {
// User facing error message
string message = 1;
// Optional agent-specific error type or category (e.g. read_content_failure, grep_execution_error, mcp_tool_error)
optional string type = 2;
// Optional status code
optional int32 status_code = 3;
}
// ConfirmationRequest is sent from the agent to client to request user permission for a ToolCall
message ConfirmationRequest {
// A list of choices for the user to select from
repeated ConfirmationOption options = 1;
// Specific details of the action requiring user confirmation
oneof details {
ExecuteDetails execute_details = 2;
FileDiff file_edit_details = 3;
McpDetails mcp_details = 4;
GenericDetails generic_details = 5;
}
}
// A single choice presented to the user during a confirmation request
message ConfirmationOption {
// Unique ID for the choice (e.g. proceed_once, cancel)
string id = 1;
// Human-readable choice (e.g. Allow Once, Reject).
string name = 2;
// An optional longer description for a tooltip
optional string description = 3;
}
// Details for a request to execute a shell command
message ExecuteDetails {
// The shell command to be executed
string command = 1;
// An optional directory in which the command will be run
optional string working_directory = 2;
}
message FileDiff {
string file_name = 1;
// The absolute path to the file to modify
string file_path = 2;
// The original content, if the file exists
optional string old_content = 3;
string new_content = 4;
// Pre-formatted diff string for display
optional string formatted_diff = 5;
}
// Details for an MCP (Model Context Protocol) tool confirmation
message McpDetails {
// The name of the MCP server that provides the tool
string server_name = 1;
// THe name of the tool being called from the MCP Server
string tool_name = 2;
}
// Generic catch-all for ToolCall requests that don't fit other types
message GenericDetails {
// Description of the action requiring confirmation
string description = 1;
}
```
**Agent Thoughts**
**Schema**
```proto
syntax = "proto3";
// Represents a thought with a subject and a detailed description.
message AgentThought {
// A concise subject line or title for the thought.
string subject = 1;
// The description or elaboration of the thought itself.
string description = 2;
}
```
**Event Metadata**
The `metadata` object in `TaskStatusUpdateEvent` is used by the A2A client to
deserialize the `TaskStatusUpdateEvents` into their appropriate objects.
**Schema**
```proto
syntax = "proto3";
// A DevelopmentToolEvent event.
message DevelopmentToolEvent {
// Enum representing the specific type of development tool event.
enum DevelopmentToolEventKind {
// The default, unspecified value.
DEVELOPMENT_TOOL_EVENT_KIND_UNSPECIFIED = 0;
TOOL_CALL_CONFIRMATION = 1;
TOOL_CALL_UPDATE = 2;
TEXT_CONTENT = 3;
STATE_CHANGE = 4;
THOUGHT = 5;
}
// The specific kind of event that occurred.
DevelopmentToolEventKind kind = 1;
// The model used for this event.
string model = 2;
// The tier of the user (optional).
string user_tier = 3;
// An unexpected error occurred in the agent execution (optional).
string error = 4;
}
```
**Client-to-Agent Messages**
When the agent sends a `TaskStatusUpdateEvent` with `status.state` set to
`input-required` and its message contains a `ConfirmationRequest`, the client
must respond by sending a new `message/stream` request.
This new request must include the `contextId` and the `taskId` from the ongoing
task and contain a `ToolCallConfirmation` object. This object conveys the user's
decision regarding the tool call that was awaiting approval.
**Schema**
```proto
syntax = "proto3";
// The client's response to a ConfirmationRequest.
message ToolCallConfirmation {
// A unique identifier, assigned by the agent
string tool_call_id = 1;
// The 'id' of the ConfirmationOption chosen by the user.
string selected_option_id = 2;
// Included if the user modifies the proposed change.
// The type should correspond to the original ConfirmationRequest details.
oneof modified_details {
// Corresponds to a FileDiff confirmation
ModifiedFileDetails file_details = 3;
}
}
message ModifiedFileDetails {
// The new content after user edits.
string new_content = 1;
}
```
### 3.3 Method Definitions
This section defines the new methods introduced by the `development-tool`
extension.
**Method: `commands/get`**
This method allows the client to discover slash commands supported by Gemini
CLI. The client should call this method during startup to dynamically populate
its command list.
```proto
// Response message containing the list of all top-level slash commands.
message GetAllSlashCommandsResponse {
// A list of the top-level slash commands.
repeated SlashCommand commands = 1;
}
// Represents a single slash command, which can contain subcommands.
message SlashCommand {
// The primary name of the command.
string name = 1;
// A detailed description of what the command does.
string description = 2;
// A list of arguments that the command accepts.
repeated SlashCommandArgument arguments = 3;
// A list of nested subcommands.
repeated SlashCommand sub_commands = 4;
}
// Defines the structure for a single slash command argument.
message SlashCommandArgument {
// The name of the argument.
string name = 1;
// A brief description of what the argument is for.
string description = 2;
// Whether the argument is required or optional.
bool is_required = 3;
}
```
**Method: `command/execute`**
This method allows the client to execute a slash command. Following the initial
`ExecuteSlashCommandResponse`, the agent will use the standard streaming
mechanism to communicate the command's progress and output. All subsequent
updates, including textual output, agent thoughts, and any required user
confirmations for tool calls (like executing a shell command), will be sent as
`TaskStatusUpdateEvent` messages, re-using the schemas defined above.
```proto
// Request to execute a specific slash command.
message ExecuteSlashCommandRequest {
// The path to the command, e.g., ["memory", "list"] for /memory list
repeated string command_path = 1;
// The arguments for the command as a single string.
string args = 2;
}
// Enum for the initial status of a command execution request.
enum CommandExecutionStatus {
// Default unspecified status.
COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0;
// The command was successfully received and its execution has started.
STARTED = 1;
// The command failed to start (e.g., command not found, invalid format).
FAILED_TO_START = 2;
// The command has been paused and is waiting for the user to confirm
// a set of shell commands.
AWAITING_SHELL_CONFIRMATION = 3;
// The command has been paused and is waiting for the user to confirm
// a specific action.
AWAITING_ACTION_CONFIRMATION = 4;
}
// The immediate, async response after requesting a command execution.
message ExecuteSlashCommandResponse {
// A unique taskID for this specific command execution.
string execution_id = 1;
// The initial status of the command execution.
CommandExecutionStatus status = 2;
// An optional message, particularly useful for explaining why a command
// failed to start.
string message = 3;
}
```
## 4. Separation of Concerns
We believe that all client-side context (ex., workspace state) and client-side
tool execution (ex. read active buffers) should be routed through MCP.
This approach enforces a strict separation of concerns: the A2A
`development-tool` extension standardizes communication to the agent, while MCP
serves as the single, authoritative interface for client-side capabilities.
## Appendix
### A. Example Interaction Flow
1. **Client -> Server**: The client sends a `message/stream` request containing
the initial prompt and configuration in an `AgentSettings` object.
2. **Server -> Client**: SSE stream begins.
- **Event 1**: The server sends a `Task` object with
`status.state: 'submitted'` and the new `taskId`.
- **Event 2**: The server sends a `TaskStatusUpdateEvent` with the metadata
`kind` set to `'STATE_CHANGE'` and `status.state` set to `'working'`.
3. **Agent Logic**: The agent processes the prompt and decides to call the
`write_file` tool, which requires user confirmation.
4. **Server -> Client**:
- **Event 3**: The server sends a `TaskStatusUpdateEvent`. The metadata
`kind` is `'TOOL_CALL_UPDATE'`, and the `DataPart` contains a `ToolCall`
object with its `status` as `'PENDING'` and a populated
`confirmation_request`.
- **Event 4**: The server sends a final `TaskStatusUpdateEvent` for this
exchange. The metadata `kind` is `'STATE_CHANGE'`, the `status.state` is
`'input-required'`, and `final` is `true`. The stream for this request
ends.
5. **Client**: The client UI renders the confirmation prompt based on the
`ToolCall` object from Event 3. The user clicks "Approve."
6. **Client -> Server**: The client sends a new `message/stream` request. It
includes the `taskId` from the ongoing task and a `DataPart` containing a
`ToolCallConfirmation` object (e.g.,
`{"tool_call_id": "...", "selected_option_id": "proceed_once"}`).
7. **Server -> Client**: A new SSE stream begins for the second request.
- **Event 1**: The server sends a `TaskStatusUpdateEvent` with
`kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` object with its
`status` now set to `'EXECUTING'`.
- **Event 2**: After the tool runs, the server sends another
`TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the
`ToolCall` with its `status` as `'SUCCEEDED'`.
8. **Agent Logic**: The agent receives the successful tool result and generates
a final textual response.
9. **Server -> Client**:
- **Event 3**: The server sends a `TaskStatusUpdateEvent` with
`kind: 'TEXT_CONTENT'` and a `TextPart` containing the agent's final
answer.
- **Event 4**: The server sends the final `TaskStatusUpdateEvent`. The
`kind` is `'STATE_CHANGE'`, the `status.state` is `'completed'`, and
`final` is `true`. The stream ends.
10. **Client**: The client displays the final answer. The task is now complete
but can be continued by sending another message with the same `taskId`.
+7
View File
@@ -0,0 +1,7 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export * from './src/index.js';
+52
View File
@@ -0,0 +1,52 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.52.0-nightly.20260707.g27a3da3e8",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
"url": "git+https://github.com/google-gemini/gemini-cli.git",
"directory": "packages/a2a-server"
},
"type": "module",
"main": "dist/index.js",
"bin": {
"gemini-cli-a2a-server": "dist/a2a-server.mjs"
},
"scripts": {
"build": "node ../../scripts/build_package.js",
"start": "node dist/src/http/server.js",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run --coverage",
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
],
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "7.19.0",
"@google/gemini-cli-core": "file:../core",
"express": "5.1.0",
"fs-extra": "11.3.0",
"strip-json-comments": "3.1.1",
"tar": "7.5.8",
"uuid": "13.0.0",
"winston": "3.17.0"
},
"devDependencies": {
"@google/genai": "1.30.0",
"@types/express": "5.0.3",
"@types/fs-extra": "11.0.4",
"@types/supertest": "6.0.3",
"@types/tar": "6.1.13",
"dotenv": "16.4.5",
"supertest": "7.1.4",
"typescript": "5.8.3",
"vitest": "3.2.4"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,303 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { CoderAgentExecutor } from './executor.js';
import type {
ExecutionEventBus,
RequestContext,
TaskStore,
} from '@a2a-js/sdk/server';
import { EventEmitter } from 'node:events';
import { requestStorage } from '../http/requestStorage.js';
// Mocks for constructor dependencies
vi.mock('../config/config.js', () => ({
loadConfig: vi.fn().mockReturnValue({
getSessionId: () => 'test-session',
getTargetDir: () => '/tmp',
getCheckpointingEnabled: () => false,
}),
loadEnvironment: vi.fn(),
setIsTrusted: vi.fn().mockReturnValue(false),
setTargetDir: vi.fn().mockReturnValue('/tmp'),
}));
vi.mock('../config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({}),
}));
vi.mock('../config/extension.js', () => ({
loadExtensions: vi.fn().mockReturnValue([]),
}));
vi.mock('../http/requestStorage.js', () => ({
requestStorage: {
getStore: vi.fn(),
},
}));
vi.mock('./task.js', () => {
const mockTaskInstance = (taskId: string, contextId: string) => ({
id: taskId,
contextId,
taskState: 'working',
acceptUserMessage: vi
.fn()
.mockImplementation(async function* (context, aborted) {
const isConfirmation = (
context.userMessage.parts as Array<{ kind: string }>
).some((p) => p.kind === 'confirmation');
// Hang only for main user messages (text), allow confirmations to finish quickly
if (!isConfirmation && aborted) {
await new Promise((resolve) => {
aborted.addEventListener('abort', resolve, { once: true });
});
}
yield { type: 'content', value: 'hello' };
}),
acceptAgentMessage: vi.fn().mockResolvedValue(undefined),
scheduleToolCalls: vi.fn().mockResolvedValue(undefined),
waitForPendingTools: vi.fn().mockResolvedValue(undefined),
getAndClearCompletedTools: vi.fn().mockReturnValue([]),
get hasPendingTools() {
return false;
},
get pendingToolsCount() {
return 0;
},
addToolResponsesToHistory: vi.fn(),
sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}),
cancelPendingTools: vi.fn(),
setTaskStateAndPublishUpdate: vi.fn(),
dispose: vi.fn(),
getMetadata: vi.fn().mockResolvedValue({}),
geminiClient: {
initialize: vi.fn().mockResolvedValue(undefined),
},
toSDKTask: () => ({
id: taskId,
contextId,
kind: 'task',
status: { state: 'working', timestamp: new Date().toISOString() },
metadata: {},
history: [],
artifacts: [],
}),
});
const MockTask = vi.fn().mockImplementation(mockTaskInstance);
(MockTask as unknown as { create: Mock }).create = vi
.fn()
.mockImplementation(async (taskId: string, contextId: string) =>
mockTaskInstance(taskId, contextId),
);
return { Task: MockTask };
});
describe('CoderAgentExecutor', () => {
let executor: CoderAgentExecutor;
let mockTaskStore: TaskStore;
let mockEventBus: ExecutionEventBus;
beforeEach(() => {
vi.clearAllMocks();
mockTaskStore = {
save: vi.fn().mockResolvedValue(undefined),
load: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
mockEventBus = new EventEmitter() as unknown as ExecutionEventBus;
mockEventBus.publish = vi.fn();
mockEventBus.finished = vi.fn();
executor = new CoderAgentExecutor(mockTaskStore);
});
it('should distinguish between primary and secondary execution', async () => {
const taskId = 'test-task';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'hi' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
// Mock requestStorage for primary
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
// First execution (Primary)
const primaryPromise = executor.execute(requestContext, mockEventBus);
// Give it enough time to reach line 490 in executor.ts
await new Promise((resolve) => setTimeout(resolve, 50));
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
const wrapper = executor.getTask(taskId);
expect(wrapper).toBeDefined();
// Mock requestStorage for secondary
const secondarySocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: secondarySocket },
});
const secondaryRequestContext = {
userMessage: {
messageId: 'msg-2',
taskId,
contextId,
parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const secondaryPromise = executor.execute(
secondaryRequestContext,
mockEventBus,
);
// Secondary execution should NOT add to executingTasks (already there)
// and should return early after its loop
await secondaryPromise;
// Task should still be in executingTasks and NOT disposed
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
expect(wrapper?.task.dispose).not.toHaveBeenCalled();
// Now simulate secondary socket closure - it should NOT affect primary
secondarySocket.emit('end');
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
expect(wrapper?.task.dispose).not.toHaveBeenCalled();
// Set to terminal state to verify disposal on finish
wrapper!.task.taskState = 'completed';
// Now close primary socket
mockSocket.emit('end');
await primaryPromise;
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(false);
expect(wrapper?.task.dispose).toHaveBeenCalled();
});
it('should evict task from cache when it reaches terminal state', async () => {
const taskId = 'test-task-terminal';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'hi' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const primaryPromise = executor.execute(requestContext, mockEventBus);
await new Promise((resolve) => setTimeout(resolve, 50));
const wrapper = executor.getTask(taskId)!;
expect(wrapper).toBeDefined();
// Simulate terminal state
wrapper.task.taskState = 'completed';
// Finish primary execution
mockSocket.emit('end');
await primaryPromise;
expect(executor.getTask(taskId)).toBeUndefined();
expect(wrapper.task.dispose).toHaveBeenCalled();
});
it('should yield the turn and transition to input-required if tools are pending', async () => {
const taskId = 'test-task-pending-tools';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
// Pre-create the task to safely modify its mocked methods before execution
const wrapper = await executor.createTask(
taskId,
contextId,
undefined,
mockEventBus,
);
const hasPendingToolsSpy = vi
.spyOn(wrapper.task, 'hasPendingTools', 'get')
.mockReturnValue(true);
vi.spyOn(wrapper.task, 'pendingToolsCount', 'get').mockReturnValue(1);
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
await executor.execute(requestContext, mockEventBus);
// Assert that the executor yielded the turn correctly without further progression
expect(hasPendingToolsSpy).toHaveBeenCalled();
expect(wrapper.task.getAndClearCompletedTools).not.toHaveBeenCalled();
expect(wrapper.task.sendCompletedToolsToLlm).not.toHaveBeenCalled();
expect(wrapper.task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
'input-required',
expect.any(Object),
undefined,
undefined,
true,
);
});
});
+673
View File
@@ -0,0 +1,673 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Message, Task as SDKTask } from '@a2a-js/sdk';
import type {
TaskStore,
AgentExecutor,
AgentExecutionEvent,
RequestContext,
ExecutionEventBus,
} from '@a2a-js/sdk/server';
import {
GeminiEventType,
SimpleExtensionLoader,
type ToolCallRequestInfo,
type Config,
} from '@google/gemini-cli-core';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
type StateChange,
type AgentSettings,
type PersistedStateMetadata,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
import {
loadConfig,
loadEnvironment,
setIsTrusted,
setTargetDir,
} from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { loadExtensions } from '../config/extension.js';
import { Task } from './task.js';
import { requestStorage } from '../http/requestStorage.js';
import { pushTaskStateFailed } from '../utils/executor_utils.js';
/**
* Provides a wrapper for Task. Passes data from Task to SDKTask.
* The idea is to use this class inside CoderAgentExecutor to replace Task.
*/
class TaskWrapper {
task: Task;
agentSettings: AgentSettings;
constructor(task: Task, agentSettings: AgentSettings) {
this.task = task;
this.agentSettings = agentSettings;
}
get id() {
return this.task.id;
}
toSDKTask(): SDKTask {
const persistedState: PersistedStateMetadata = {
_agentSettings: this.agentSettings,
_taskState: this.task.taskState,
};
const sdkTask: SDKTask = {
id: this.task.id,
contextId: this.task.contextId,
kind: 'task',
status: {
state: this.task.taskState,
timestamp: new Date().toISOString(),
},
metadata: setPersistedState({}, persistedState),
history: [],
artifacts: [],
};
sdkTask.metadata!['_contextId'] = this.task.contextId;
return sdkTask;
}
}
/**
* CoderAgentExecutor implements the agent's core logic for code generation.
*/
export class CoderAgentExecutor implements AgentExecutor {
private tasks: Map<string, TaskWrapper> = new Map();
// Track tasks with an active execution loop.
private executingTasks = new Set<string>();
constructor(private taskStore?: TaskStore) {}
private async getConfig(
agentSettings: AgentSettings,
taskId: string,
): Promise<Config> {
const workspaceRoot = setTargetDir(agentSettings);
loadEnvironment(); // Will override any global env with workspace envs
const isTrusted = setIsTrusted(agentSettings);
const settings = loadSettings(workspaceRoot, isTrusted);
const extensions = loadExtensions(workspaceRoot);
return loadConfig(
settings,
new SimpleExtensionLoader(extensions),
taskId,
isTrusted,
);
}
/**
* Reconstructs TaskWrapper from SDKTask.
*/
async reconstruct(
sdkTask: SDKTask,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
const metadata = sdkTask.metadata || {};
const persistedState = getPersistedState(metadata);
if (!persistedState) {
throw new Error(
`Cannot reconstruct task ${sdkTask.id}: missing persisted state in metadata.`,
);
}
const agentSettings = persistedState._agentSettings;
const config = await this.getConfig(agentSettings, sdkTask.id);
const contextId: string =
getContextIdFromMetadata(metadata) || sdkTask.contextId;
const runtimeTask = await Task.create(
sdkTask.id,
contextId,
config,
eventBus,
agentSettings.autoExecute,
);
runtimeTask.taskState = persistedState._taskState;
await runtimeTask.geminiClient.initialize();
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
this.tasks.set(sdkTask.id, wrapper);
logger.info(`Task ${sdkTask.id} reconstructed from store.`);
return wrapper;
}
async createTask(
taskId: string,
contextId: string,
agentSettingsInput?: AgentSettings,
eventBus?: ExecutionEventBus,
): Promise<TaskWrapper> {
const agentSettings: AgentSettings = agentSettingsInput || {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: process.cwd(),
};
const config = await this.getConfig(agentSettings, taskId);
const runtimeTask = await Task.create(
taskId,
contextId,
config,
eventBus,
agentSettings.autoExecute,
);
await runtimeTask.geminiClient.initialize();
const wrapper = new TaskWrapper(runtimeTask, agentSettings);
this.tasks.set(taskId, wrapper);
logger.info(`New task ${taskId} created.`);
return wrapper;
}
getTask(taskId: string): TaskWrapper | undefined {
return this.tasks.get(taskId);
}
getAllTasks(): TaskWrapper[] {
return Array.from(this.tasks.values());
}
cancelTask = async (
taskId: string,
eventBus: ExecutionEventBus,
): Promise<void> => {
logger.info(
`[CoderAgentExecutor] Received cancel request for task ${taskId}`,
);
const wrapper = this.tasks.get(taskId);
if (!wrapper) {
logger.warn(
`[CoderAgentExecutor] Task ${taskId} not found for cancellation.`,
);
eventBus.publish({
kind: 'status-update',
taskId,
contextId: uuidv4(),
status: {
state: 'failed',
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: `Task ${taskId} not found.` }],
messageId: uuidv4(),
taskId,
},
},
final: true,
});
return;
}
const { task } = wrapper;
if (task.taskState === 'canceled' || task.taskState === 'failed') {
logger.info(
`[CoderAgentExecutor] Task ${taskId} is already in a final state: ${task.taskState}. No action needed for cancellation.`,
);
eventBus.publish({
kind: 'status-update',
taskId,
contextId: task.contextId,
status: {
state: task.taskState,
message: {
kind: 'message',
role: 'agent',
parts: [
{
kind: 'text',
text: `Task ${taskId} is already ${task.taskState}.`,
},
],
messageId: uuidv4(),
taskId,
},
},
final: true,
});
return;
}
try {
logger.info(
`[CoderAgentExecutor] Initiating cancellation for task ${taskId}.`,
);
task.cancelPendingTools('Task canceled by user request.');
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
task.setTaskStateAndPublishUpdate(
'canceled',
stateChange,
'Task canceled by user request.',
undefined,
true,
);
logger.info(
`[CoderAgentExecutor] Task ${taskId} cancellation processed. Saving state.`,
);
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`);
// Cleanup listener subscriptions to avoid memory leaks.
wrapper.task.dispose();
this.tasks.delete(taskId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error(
`[CoderAgentExecutor] Error during task cancellation for ${taskId}: ${errorMessage}`,
error,
);
eventBus.publish({
kind: 'status-update',
taskId,
contextId: task.contextId,
status: {
state: 'failed',
message: {
kind: 'message',
role: 'agent',
parts: [
{
kind: 'text',
text: `Failed to process cancellation for task ${taskId}: ${errorMessage}`,
},
],
messageId: uuidv4(),
taskId,
},
},
final: true,
});
}
};
async execute(
requestContext: RequestContext,
eventBus: ExecutionEventBus,
): Promise<void> {
const userMessage = requestContext.userMessage;
const sdkTask = requestContext.task;
const taskId = sdkTask?.id || userMessage.taskId || uuidv4();
const contextId: string =
userMessage.contextId ||
sdkTask?.contextId ||
getContextIdFromMetadata(sdkTask?.metadata) ||
uuidv4();
logger.info(
`[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`,
);
logger.info(
`[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`,
);
eventBus.on('event', (event: AgentExecutionEvent) =>
logger.info('[EventBus event]: ', event),
);
const store = requestStorage.getStore();
if (!store) {
logger.error(
'[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.',
);
}
const abortController = new AbortController();
const abortSignal = abortController.signal;
if (store) {
// Grab the raw socket from the request object
const socket = store.req.socket;
const onSocketEnd = () => {
logger.info(
`[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`,
);
if (!abortController.signal.aborted) {
abortController.abort();
}
// Clean up the listener to prevent memory leaks
socket.removeListener('end', onSocketEnd);
};
// Listen on the socket's 'end' event (remote closed the connection)
socket.on('end', onSocketEnd);
socket.once('close', () => {
socket.removeListener('end', onSocketEnd);
});
// It's also good practice to remove the listener if the task completes successfully
abortSignal.addEventListener('abort', () => {
socket.removeListener('end', onSocketEnd);
});
logger.info(
`[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`,
);
}
let wrapper: TaskWrapper | undefined = this.tasks.get(taskId);
if (wrapper) {
wrapper.task.eventBus = eventBus;
logger.info(`[CoderAgentExecutor] Task ${taskId} found in memory cache.`);
} else if (sdkTask) {
logger.info(
`[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`,
);
try {
wrapper = await this.reconstruct(sdkTask, eventBus);
} catch (e) {
logger.error(
`[CoderAgentExecutor] Failed to hydrate task ${taskId}:`,
e,
);
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
eventBus.publish({
kind: 'status-update',
taskId,
contextId: sdkTask.contextId,
status: {
state: 'failed',
message: {
kind: 'message',
role: 'agent',
parts: [
{
kind: 'text',
text: 'Internal error: Task state lost or corrupted.',
},
],
messageId: uuidv4(),
taskId,
contextId: sdkTask.contextId,
} as Message,
},
final: true,
metadata: { coderAgent: stateChange },
});
return;
}
} else {
logger.info(`[CoderAgentExecutor] Creating new task ${taskId}.`);
const agentSettings = getAgentSettingsFromMetadata(userMessage.metadata);
try {
wrapper = await this.createTask(
taskId,
contextId,
agentSettings,
eventBus,
);
} catch (error) {
logger.error(
`[CoderAgentExecutor] Error creating task ${taskId}:`,
error,
);
// eslint-disable-next-line @typescript-eslint/no-floating-promises
pushTaskStateFailed(error, eventBus, taskId, contextId);
return;
}
const newTaskSDK = wrapper.toSDKTask();
eventBus.publish({
...newTaskSDK,
kind: 'task',
status: { state: 'submitted', timestamp: new Date().toISOString() },
history: [userMessage],
});
try {
await this.taskStore?.save(newTaskSDK);
logger.info(`[CoderAgentExecutor] New task ${taskId} saved to store.`);
} catch (saveError) {
logger.error(
`[CoderAgentExecutor] Failed to save new task ${taskId} to store:`,
saveError,
);
}
}
if (!wrapper) {
logger.error(
`[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`,
);
return;
}
const currentTask = wrapper.task;
if (['canceled', 'failed', 'completed'].includes(currentTask.taskState)) {
logger.warn(
`[CoderAgentExecutor] Attempted to execute task ${taskId} which is already in state ${currentTask.taskState}. Ignoring.`,
);
return;
}
if (this.executingTasks.has(taskId)) {
logger.info(
`[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`,
);
currentTask.eventBus = eventBus;
for await (const _ of currentTask.acceptUserMessage(
requestContext,
abortController.signal,
)) {
logger.info(
`[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`,
);
}
// End this execution-- the original/source will be resumed.
return;
}
// Check if this is the primary/initial execution for this task
const isPrimaryExecution = !this.executingTasks.has(taskId);
if (!isPrimaryExecution) {
logger.info(
`[CoderAgentExecutor] Primary execution already active for task ${taskId}. Starting secondary loop for message ${userMessage.messageId}.`,
);
currentTask.eventBus = eventBus;
for await (const _ of currentTask.acceptUserMessage(
requestContext,
abortController.signal,
)) {
logger.info(
`[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`,
);
}
// End this execution-- the original/source will be resumed.
return;
}
logger.info(
`[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`,
);
this.executingTasks.add(taskId);
try {
let agentTurnActive = true;
logger.info(`[CoderAgentExecutor] Task ${taskId}: Processing user turn.`);
let agentEvents = currentTask.acceptUserMessage(
requestContext,
abortSignal,
);
while (agentTurnActive) {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`,
);
const toolCallRequests: ToolCallRequestInfo[] = [];
for await (const event of agentEvents) {
if (abortSignal.aborted) {
logger.warn(
`[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`,
);
throw new Error('Execution aborted');
}
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
continue;
}
await currentTask.acceptAgentMessage(event);
}
if (abortSignal.aborted) throw new Error('Execution aborted');
if (toolCallRequests.length > 0) {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`,
);
await currentTask.scheduleToolCalls(toolCallRequests, abortSignal);
}
logger.info(
`[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`,
);
await currentTask.waitForPendingTools();
logger.info(
`[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`,
);
if (abortSignal.aborted) throw new Error('Execution aborted');
if (currentTask.hasPendingTools) {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: There are still ${currentTask.pendingToolsCount} pending tools waiting for approval. Yielding to user.`,
);
agentTurnActive = false;
} else {
const completedTools = currentTask.getAndClearCompletedTools();
if (completedTools.length > 0) {
// If all completed tool calls were canceled, manually add them to history and set state to input-required, final:true
if (completedTools.every((tool) => tool.status === 'cancelled')) {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`,
);
currentTask.addToolResponsesToHistory(completedTools);
agentTurnActive = false;
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
currentTask.setTaskStateAndPublishUpdate(
'input-required',
stateChange,
undefined,
undefined,
true,
);
} else {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`,
);
agentEvents = currentTask.sendCompletedToolsToLlm(
completedTools,
abortSignal,
);
// Continue the loop to process the LLM response to the tool results.
}
} else {
logger.info(
`[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`,
);
agentTurnActive = false;
}
}
}
logger.info(
`[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`,
);
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
currentTask.setTaskStateAndPublishUpdate(
'input-required',
stateChange,
undefined,
undefined,
true,
);
} catch (error) {
if (abortSignal.aborted) {
logger.warn(`[CoderAgentExecutor] Task ${taskId} execution aborted.`);
currentTask.cancelPendingTools('Execution aborted');
if (
currentTask.taskState !== 'canceled' &&
currentTask.taskState !== 'failed'
) {
currentTask.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
'Execution aborted by client.',
undefined,
true,
);
}
} else {
const errorMessage =
error instanceof Error ? error.message : 'Agent execution error';
logger.error(
`[CoderAgentExecutor] Error executing agent for task ${taskId}:`,
error,
);
currentTask.cancelPendingTools(errorMessage);
if (currentTask.taskState !== 'failed') {
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
currentTask.setTaskStateAndPublishUpdate(
'failed',
stateChange,
errorMessage,
undefined,
true,
);
}
}
} finally {
if (isPrimaryExecution) {
this.executingTasks.delete(taskId);
logger.info(
`[CoderAgentExecutor] Saving final state for task ${taskId}.`,
);
try {
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
} catch (saveError) {
logger.error(
`[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
saveError,
);
}
if (
['canceled', 'failed', 'completed'].includes(currentTask.taskState)
) {
logger.info(
`[CoderAgentExecutor] Task ${taskId} reached terminal state ${currentTask.taskState}. Evicting and disposing.`,
);
wrapper.task.dispose();
this.tasks.delete(taskId);
}
}
}
}
}
@@ -0,0 +1,173 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
MessageBusType,
CoreToolCallStatus,
type Config,
type MessageBus,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { RequestContext } from '@a2a-js/sdk/server';
describe('Task Race Condition', () => {
let mockConfig: Config;
let messageBus: MessageBus;
beforeEach(() => {
messageBus = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
publish: vi.fn(),
} as unknown as MessageBus;
mockConfig = createMockConfig({
messageBus,
}) as Config;
});
it('should not hang when multiple tool confirmations are processed while waiting', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register two tools as scheduled
task['_registerToolCall']('tool-1', 'scheduled');
task['_registerToolCall']('tool-2', 'scheduled');
// 2. Both transition to awaiting_approval
const updateHandler = (messageBus.subscribe as Mock).mock.calls.find(
(c: unknown[]) => c[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
updateHandler({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'task-id',
toolCalls: [
{
request: { callId: 'tool-1', name: 't1' },
status: CoreToolCallStatus.AwaitingApproval,
correlationId: 'corr-1',
confirmationDetails: { type: 'info' },
},
{
request: { callId: 'tool-2', name: 't2' },
status: CoreToolCallStatus.AwaitingApproval,
correlationId: 'corr-2',
confirmationDetails: { type: 'info' },
},
],
});
// 3. Confirm Tool 1. This makes isAwaitingApprovalOnly() return false.
for await (const _ of task.acceptUserMessage(
{
userMessage: {
parts: [
{
kind: 'data',
data: { callId: 'tool-1', outcome: 'proceed_once' },
},
],
},
} as unknown as RequestContext,
new AbortController().signal,
)) {
// consume generator
}
// 4. Start waiting. This should now block because Tool 1 is confirmed (so we are waiting for its execution).
const waitPromise = task.waitForPendingTools();
// 5. Confirm Tool 2 while waiting.
for await (const _ of task.acceptUserMessage(
{
userMessage: {
parts: [
{
kind: 'data',
data: { callId: 'tool-2', outcome: 'proceed_once' },
},
],
},
} as unknown as RequestContext,
new AbortController().signal,
)) {
// consume generator
}
// 6. Both tools complete successfully
updateHandler({
type: MessageBusType.TOOL_CALLS_UPDATE,
schedulerId: 'task-id',
toolCalls: [
{
request: { callId: 'tool-1', name: 't1' },
status: CoreToolCallStatus.Success,
response: { responseParts: [] },
},
{
request: { callId: 'tool-2', name: 't2' },
status: CoreToolCallStatus.Success,
response: { responseParts: [] },
},
],
});
// 7. Verify that the original waitPromise resolves.
await expect(waitPromise).resolves.toBeUndefined();
});
it('should reject waitForPendingTools when tools are cancelled', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register a tool
task['_registerToolCall']('tool-1', 'scheduled');
// 2. Start waiting
const waitPromise = task.waitForPendingTools();
// 3. Cancel pending tools
task.cancelPendingTools('User requested cancellation');
// 4. Verify waitPromise rejects with the reason
await expect(waitPromise).rejects.toThrow('User requested cancellation');
});
it('should handle concurrent tool scheduling correctly', async () => {
// @ts-expect-error - private constructor
const task = new Task('task-id', 'context-id', mockConfig);
// 1. Register a tool and start waiting
task['_registerToolCall']('tool-1', 'scheduled');
const waitPromise = task.waitForPendingTools();
// 2. Schedule another tool concurrently (e.g. from a secondary user message)
// This should NOT resolve the current waitPromise until both are done
await task.scheduleToolCalls(
[{ callId: 'tool-2', name: 't2', args: {} }],
new AbortController().signal,
);
expect(task['pendingToolCalls'].size).toBe(2);
// 3. Resolve tool 1
task['_resolveToolCall']('tool-1');
// 4. Verify waitPromise is still pending
let resolved = false;
waitPromise.then(() => (resolved = true));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(resolved).toBe(false);
// 5. Resolve tool 2
task['_resolveToolCall']('tool-2');
// 6. Now it should resolve
await expect(waitPromise).resolves.toBeUndefined();
});
});
@@ -0,0 +1,762 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
type Config,
MessageBusType,
ToolConfirmationOutcome,
ApprovalMode,
Scheduler,
type MessageBus,
type ToolLiveOutput,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
describe('Task Event-Driven Scheduler', () => {
let mockConfig: Config;
let mockEventBus: ExecutionEventBus;
let messageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
}) as Config;
messageBus = mockConfig.messageBus;
mockEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
});
it('should instantiate Scheduler when enabled', () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
expect(task.scheduler).toBeInstanceOf(Scheduler);
});
it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({
state: 'submitted', // initial task state
}),
metadata: expect.objectContaining({
coderAgent: expect.objectContaining({
kind: 'tool-call-update',
}),
}),
}),
);
});
it('should handle tool confirmations by publishing to MessageBus', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
// Simulate MessageBus event to stash the correlationId
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Simulate A2A client confirmation
const part = {
kind: 'data',
data: {
callId: '1',
outcome: 'proceed_once',
},
};
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart(part);
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Simulate Rejection (Cancel)
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'cancel' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: false,
}),
);
const toolCall2 = {
request: { callId: '2', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall2],
schedulerId: 'task-id',
});
// Simulate ModifyWithEditor
const handled2 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '2', outcome: 'modify_with_editor' },
});
expect(handled2).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-2',
confirmed: false,
outcome: ToolConfirmationOutcome.ModifyWithEditor,
payload: undefined,
}),
);
});
it('should handle MCP Server tool operations correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-1',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Simulate ProceedOnce for MCP
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_once' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-2',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_server' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-2',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysServer,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-3',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_tool' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-3',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysTool,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-4',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_and_save' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-4',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
}),
);
});
it('should execute without confirmation in YOLO mode and not transition to input-required', async () => {
// Enable YOLO mode
const yoloConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
getApprovalMode: () => ApprovalMode.YOLO,
}) as Config;
const yoloMessageBus = yoloConfig.messageBus;
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
task.setTaskStateAndPublishUpdate = vi.fn();
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
}),
);
// Should NOT transition to input-required since it was auto-approved
expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
});
it('should handle output updates via the message bus', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
liveOutput: 'chunk1',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// Should publish artifact update for output
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
artifactId: 'tool-1-output',
parts: [{ kind: 'text', text: 'chunk1' }],
}),
}),
);
});
it('should complete artifact creation without hanging', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCallId = 'create-file-123';
task['_registerToolCall'](toolCallId, 'executing');
const toolCall = {
request: {
callId: toolCallId,
name: 'writeFile',
args: { path: 'test.sh' },
},
status: 'success',
result: { ok: true },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
schedulerId: 'task-id',
});
// The tool should be complete and registered appropriately, eventually
// triggering the toolCompletionPromise resolution when all clear.
const internalTask = task as unknown as {
completedToolCalls: unknown[];
pendingToolCalls: Map<string, string>;
};
expect(internalTask.completedToolCalls.length).toBe(1);
expect(internalTask.pendingToolCalls.size).toBe(0);
});
it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
// Initialize the ID for the first turn (happens internally upon LLM stream)
task.currentAgentMessageId = 'test-id-123';
// Simulate sending multiple text chunks
task._sendTextContent('chunk 1');
task._sendTextContent('chunk 2');
// Both text contents should have been published with the same messageId
const textCalls = (mockEventBus.publish as Mock).mock.calls.filter(
(call) => call[0].status?.message?.kind === 'message',
);
expect(textCalls.length).toBe(2);
expect(textCalls[0][0].status.message.messageId).toBe('test-id-123');
expect(textCalls[1][0].status.message.messageId).toBe('test-id-123');
// Simulate starting a new turn by calling getAndClearCompletedTools
// (which precedes sendCompletedToolsToLlm where a new ID is minted)
task.getAndClearCompletedTools();
// sendCompletedToolsToLlm internally rolls the ID forward.
// Simulate what sendCompletedToolsToLlm does:
const internalTask = task as unknown as {
setTaskStateAndPublishUpdate: (state: string, change: unknown) => void;
};
internalTask.setTaskStateAndPublishUpdate('working', {});
// Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn
task.currentAgentMessageId = 'test-id-456';
task._sendTextContent('chunk 3');
const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter(
(call) => call[0].status?.message?.messageId === 'test-id-456',
);
expect(secondTurnCalls.length).toBe(1);
expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3');
});
it('should handle parallel tool calls correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall1 = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' },
};
const toolCall2 = {
request: { callId: '2', name: 'pwd', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
// Publish update for both tool calls simultaneously
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Confirm first tool call
const handled1 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_once' },
});
expect(handled1).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
}),
);
// Confirm second tool call
const handled2 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '2', outcome: 'cancel' },
});
expect(handled2).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-2',
confirmed: false,
}),
);
});
it('should handle multi-turn tool resolution correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig);
task['_registerToolCall']('1', 'scheduled');
task['_registerToolCall']('2', 'scheduled');
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
// Turn 1: Resolve tool 1
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
request: { callId: '1', name: 't1' },
status: 'success',
response: { responseParts: [] },
},
],
schedulerId: 'task-id',
});
expect(task['pendingToolCalls'].size).toBe(1);
expect(task['pendingToolCalls'].has('2')).toBe(true);
// Turn 2: Resolve tool 2
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
request: { callId: '2', name: 't2' },
status: 'success',
response: { responseParts: [] },
},
],
schedulerId: 'task-id',
});
expect(task['pendingToolCalls'].size).toBe(0);
});
it('should handle subagent progress events from the scheduler', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
// Trigger _schedulerOutputUpdate with subagent progress
task['_schedulerOutputUpdate']('tool-1', {
isSubagentProgress: true,
agentName: 'researcher',
recentActivity: [],
} as ToolLiveOutput);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
parts: [
expect.objectContaining({
text: expect.stringContaining('researcher'),
}),
],
}),
}),
);
});
it('should wait for executing tools before transitioning to input-required state', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
task.setTaskStateAndPublishUpdate = vi.fn();
// Register tool 1 as executing
task['_registerToolCall']('1', 'executing');
const toolCall1 = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
const toolCall2 = {
request: { callId: '2', name: 'pwd', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
schedulerId: 'task-id',
});
// Should NOT transition to input-required yet
expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
// Complete tool 1
const toolCall1Complete = {
...toolCall1,
status: 'success',
result: { ok: true },
};
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1Complete, toolCall2],
schedulerId: 'task-id',
});
// Now it should transition
expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
});
it('should ignore confirmations for unknown tool calls', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: 'unknown-id', outcome: 'proceed_once' },
});
// Should return false for unhandled tool call
expect(handled).toBe(false);
// Should not publish anything to the message bus
expect(messageBus.publish).not.toHaveBeenCalled();
});
});
+755
View File
@@ -0,0 +1,755 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
GeminiEventType,
type Config,
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
type ToolCall,
type ToolCallsUpdateMessage,
MessageBusType,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
import { CoderAgentEvent } from '../types.js';
const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
processRestorableToolCalls: mockProcessRestorableToolCalls,
};
});
describe('Task', () => {
it('scheduleToolCalls should not modify the input requests array', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// The Task constructor is private. We'll bypass it for this unit test.
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
task['setTaskStateAndPublishUpdate'] = vi.fn();
task['getProposedContent'] = vi.fn().mockResolvedValue('new content');
const requests: ToolCallRequestInfo[] = [
{
callId: '1',
name: 'replace',
args: {
file_path: 'test.txt',
old_string: 'old',
new_string: 'new',
},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
];
const originalRequests = JSON.parse(JSON.stringify(requests));
const abortController = new AbortController();
await task.scheduleToolCalls(requests, abortController.signal);
expect(requests).toEqual(originalRequests);
});
describe('scheduleToolCalls', () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should not create a checkpoint if no restorable tools are called', async () => {
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const requests: ToolCallRequestInfo[] = [
{
callId: '1',
name: 'run_shell_command',
args: { command: 'ls' },
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
];
const abortController = new AbortController();
await task.scheduleToolCalls(requests, abortController.signal);
expect(mockProcessRestorableToolCalls).not.toHaveBeenCalled();
});
it('should create a checkpoint if a restorable tool is called', async () => {
const mockConfig = createMockConfig({
getCheckpointingEnabled: () => true,
getGitService: () => Promise.resolve({} as GitService),
});
mockProcessRestorableToolCalls.mockResolvedValue({
checkpointsToWrite: new Map([['test.json', 'test content']]),
toolCallToCheckpointMap: new Map(),
errors: [],
});
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const requests: ToolCallRequestInfo[] = [
{
callId: '1',
name: 'replace',
args: {
file_path: 'test.txt',
old_string: 'old',
new_string: 'new',
},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
];
const abortController = new AbortController();
await task.scheduleToolCalls(requests, abortController.signal);
expect(mockProcessRestorableToolCalls).toHaveBeenCalledOnce();
});
it('should process all restorable tools for checkpointing in a single batch', async () => {
const mockConfig = createMockConfig({
getCheckpointingEnabled: () => true,
getGitService: () => Promise.resolve({} as GitService),
});
mockProcessRestorableToolCalls.mockResolvedValue({
checkpointsToWrite: new Map([
['test1.json', 'test content 1'],
['test2.json', 'test content 2'],
]),
toolCallToCheckpointMap: new Map([
['1', 'test1'],
['2', 'test2'],
]),
errors: [],
});
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const requests: ToolCallRequestInfo[] = [
{
callId: '1',
name: 'replace',
args: {
file_path: 'test.txt',
old_string: 'old',
new_string: 'new',
},
isClientInitiated: false,
prompt_id: 'prompt-id-1',
},
{
callId: '2',
name: 'write_file',
args: { file_path: 'test2.txt', content: 'new content' },
isClientInitiated: false,
prompt_id: 'prompt-id-2',
},
{
callId: '3',
name: 'not_restorable',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
];
const abortController = new AbortController();
await task.scheduleToolCalls(requests, abortController.signal);
expect(mockProcessRestorableToolCalls).toHaveBeenCalledExactlyOnceWith(
[
expect.objectContaining({ callId: '1' }),
expect.objectContaining({ callId: '2' }),
],
expect.anything(),
expect.anything(),
);
});
});
describe('acceptAgentMessage', () => {
it('should set currentTraceId when event has traceId', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const event = {
type: 'content',
value: 'test',
traceId: 'test-trace-id',
};
await task.acceptAgentMessage(event);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
traceId: 'test-trace-id',
}),
}),
);
});
it('should handle Citation event and publish to event bus', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const citationText = 'Source: example.com';
const citationEvent = {
type: GeminiEventType.Citation,
value: citationText,
};
await task.acceptAgentMessage(citationEvent);
expect(mockEventBus.publish).toHaveBeenCalledOnce();
const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0];
expect(publishedEvent.kind).toBe('status-update');
expect(publishedEvent.taskId).toBe('task-id');
expect(publishedEvent.metadata.coderAgent.kind).toBe(
CoderAgentEvent.CitationEvent,
);
expect(publishedEvent.status.message).toBeDefined();
expect(publishedEvent.status.message.parts).toEqual([
{
kind: 'text',
text: citationText,
},
]);
});
it('should capture usageMetadata on Finished event and include it in final status update', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const finishedEvent = {
type: GeminiEventType.Finished,
value: {
reason: 'STOP',
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
},
};
await task.acceptAgentMessage(finishedEvent);
expect(task.usageMetadata).toEqual({
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
});
task.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
undefined,
undefined,
true, // final
);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
final: true,
metadata: expect.objectContaining({
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
}),
}),
);
});
it('should update modelInfo and reflect it in metadata and status updates', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const modelInfoEvent = {
type: GeminiEventType.ModelInfo,
value: 'new-model-name',
};
await task.acceptAgentMessage(modelInfoEvent);
expect(task.modelInfo).toBe('new-model-name');
// Check getMetadata
const metadata = await task.getMetadata();
expect(metadata.model).toBe('new-model-name');
// Check status update
task.setTaskStateAndPublishUpdate(
'working',
{ kind: CoderAgentEvent.StateChangeEvent },
'Working...',
);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
model: 'new-model-name',
}),
}),
);
});
it.each([
{ eventType: GeminiEventType.Retry, eventName: 'Retry' },
{ eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' },
])(
'should handle $eventName event without triggering error handling',
async ({ eventType }) => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools');
const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate');
const event = {
type: eventType,
};
await task.acceptAgentMessage(event);
expect(cancelPendingToolsSpy).not.toHaveBeenCalled();
expect(setTaskStateSpy).not.toHaveBeenCalled();
},
);
});
describe('currentPromptId and promptCount', () => {
it('should correctly initialize and update promptId and promptCount', async () => {
const mockConfig = createMockConfig();
mockConfig.getGeminiClient = vi.fn().mockReturnValue({
sendMessageStream: vi.fn().mockReturnValue((async function* () {})()),
});
mockConfig.getSessionId = () => 'test-session-id';
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Initial state
expect(task.currentPromptId).toBeUndefined();
expect(task.promptCount).toBe(0);
// First user message should set prompt_id
const userMessage1 = {
userMessage: {
parts: [{ kind: 'text', text: 'hello' }],
},
} as RequestContext;
const abortController1 = new AbortController();
for await (const _ of task.acceptUserMessage(
userMessage1,
abortController1.signal,
)) {
// no-op
}
const expectedPromptId1 = 'test-session-id########0';
expect(task.promptCount).toBe(1);
expect(task.currentPromptId).toBe(expectedPromptId1);
// A new user message should generate a new prompt_id
const userMessage2 = {
userMessage: {
parts: [{ kind: 'text', text: 'world' }],
},
} as RequestContext;
const abortController2 = new AbortController();
for await (const _ of task.acceptUserMessage(
userMessage2,
abortController2.signal,
)) {
// no-op
}
const expectedPromptId2 = 'test-session-id########1';
expect(task.promptCount).toBe(2);
expect(task.currentPromptId).toBe(expectedPromptId2);
// Subsequent tool call processing should use the same prompt_id
const completedTool = {
request: { callId: 'tool-1' },
response: { responseParts: [{ text: 'tool output' }] },
} as CompletedToolCall;
const abortController3 = new AbortController();
for await (const _ of task.sendCompletedToolsToLlm(
[completedTool],
abortController3.signal,
)) {
// no-op
}
expect(task.promptCount).toBe(2);
expect(task.currentPromptId).toBe(expectedPromptId2);
});
});
describe('Race Condition Fix', () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should NOT transition to input-required if a tool is still validating', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Manually register two tool calls
task['_registerToolCall']('tool-1', 'awaiting_approval');
task['_registerToolCall']('tool-2', 'validating');
// Call checkInputRequiredState (private)
task['checkInputRequiredState']();
// Verify task state did NOT change to input-required
expect(task.taskState).not.toBe('input-required');
expect(mockEventBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({ state: 'input-required' }),
}),
);
});
it('should transition to input-required if all active tools are awaiting approval', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
// Transition from submitted to working first to simulate normal flow
task.taskState = 'working';
// Manually register tool calls
task['_registerToolCall']('tool-1', 'awaiting_approval');
// Call checkInputRequiredState
task['checkInputRequiredState']();
// Verify task state changed to input-required
expect(task.taskState).toBe('input-required');
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({ state: 'input-required' }),
}),
);
});
it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const handleEventDrivenToolCallSpy = vi.spyOn(
task as unknown as {
handleEventDrivenToolCall: Task['handleEventDrivenToolCall'];
},
'handleEventDrivenToolCall',
);
const otherEvent: ToolCallsUpdateMessage = {
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
],
schedulerId: 'other-task-id',
};
task['handleEventDrivenToolCallsUpdate'](otherEvent);
expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled();
const ownEvent: ToolCallsUpdateMessage = {
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{ request: { callId: '1' }, status: 'executing' } as ToolCall,
],
schedulerId: 'task-id',
};
task['handleEventDrivenToolCallsUpdate'](ownEvent);
expect(handleEventDrivenToolCallSpy).toHaveBeenCalled();
});
describe('Pending Tools state', () => {
it('should correctly report pending tools presence and count', () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
expect(task.hasPendingTools).toBe(false);
expect(task.pendingToolsCount).toBe(0);
task['_registerToolCall']('tool-1', 'scheduled');
expect(task.hasPendingTools).toBe(true);
expect(task.pendingToolsCount).toBe(1);
});
});
});
describe('Serialization and Mapping', () => {
it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const mockToolCall = {
request: { callId: 'tool-1' },
status: 'validating',
outcome: 'accepted',
tool: { name: 'test-tool' },
};
const message = task['toolStatusMessage'](
mockToolCall as unknown as ToolCall,
'task-id',
'context-id',
);
const serialized = (
message.parts![0] as {
data: { status: string; outcome: string };
}
).data;
expect(serialized.status).toBe('scheduled');
expect(serialized.outcome).toBe('accepted');
});
it('should correctly detect changes when status or outcome changes', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const toolCall1 = {
request: { callId: 'tool-1' },
status: 'awaiting_approval',
};
// First update - should trigger change
const changed1 = task['handleEventDrivenToolCall'](
toolCall1 as unknown as ToolCall,
);
expect(changed1).toBe(true);
// Second update with same status - should NOT trigger change
const changed2 = task['handleEventDrivenToolCall'](
toolCall1 as unknown as ToolCall,
);
expect(changed2).toBe(false);
// Update with new outcome - SHOULD trigger change
const toolCall2 = {
request: { callId: 'tool-1' },
status: 'awaiting_approval',
outcome: 'accepted',
};
const changed3 = task['handleEventDrivenToolCall'](
toolCall2 as unknown as ToolCall,
);
expect(changed3).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Command } from './types.js';
const {
mockExtensionsCommand,
mockListExtensionsCommand,
mockExtensionsCommandInstance,
mockListExtensionsCommandInstance,
} = vi.hoisted(() => {
const listInstance: Command = {
name: 'extensions list',
description: 'Lists all installed extensions.',
execute: vi.fn(),
};
const extInstance: Command = {
name: 'extensions',
description: 'Manage extensions.',
execute: vi.fn(),
subCommands: [listInstance],
};
return {
mockListExtensionsCommandInstance: listInstance,
mockExtensionsCommandInstance: extInstance,
mockExtensionsCommand: vi.fn(() => extInstance),
mockListExtensionsCommand: vi.fn(() => listInstance),
};
});
vi.mock('./extensions.js', () => ({
ExtensionsCommand: mockExtensionsCommand,
ListExtensionsCommand: mockListExtensionsCommand,
}));
vi.mock('./init.js', () => ({
InitCommand: vi.fn(() => ({
name: 'init',
description: 'Initializes the server.',
execute: vi.fn(),
})),
}));
vi.mock('./restore.js', () => ({
RestoreCommand: vi.fn(() => ({
name: 'restore',
description: 'Restores the server.',
execute: vi.fn(),
})),
}));
import { commandRegistry } from './command-registry.js';
describe('CommandRegistry', () => {
beforeEach(async () => {
vi.clearAllMocks();
commandRegistry.initialize();
});
it('should register ExtensionsCommand on initialization', async () => {
expect(mockExtensionsCommand).toHaveBeenCalled();
const command = commandRegistry.get('extensions');
expect(command).toBe(mockExtensionsCommandInstance);
}, 20000);
it('should register sub commands on initialization', async () => {
const command = commandRegistry.get('extensions list');
expect(command).toBe(mockListExtensionsCommandInstance);
});
it('get() should return undefined for a non-existent command', async () => {
const command = commandRegistry.get('non-existent');
expect(command).toBeUndefined();
});
it('register() should register a new command', async () => {
const mockCommand: Command = {
name: 'test-command',
description: '',
execute: vi.fn(),
};
commandRegistry.register(mockCommand);
const command = commandRegistry.get('test-command');
expect(command).toBe(mockCommand);
});
it('register() should register a nested command', async () => {
const mockSubSubCommand: Command = {
name: 'test-command-sub-sub',
description: '',
execute: vi.fn(),
};
const mockSubCommand: Command = {
name: 'test-command-sub',
description: '',
execute: vi.fn(),
subCommands: [mockSubSubCommand],
};
const mockCommand: Command = {
name: 'test-command',
description: '',
execute: vi.fn(),
subCommands: [mockSubCommand],
};
commandRegistry.register(mockCommand);
const command = commandRegistry.get('test-command');
const subCommand = commandRegistry.get('test-command-sub');
const subSubCommand = commandRegistry.get('test-command-sub-sub');
expect(command).toBe(mockCommand);
expect(subCommand).toBe(mockSubCommand);
expect(subSubCommand).toBe(mockSubSubCommand);
});
it('register() should not enter an infinite loop with a cyclic command', async () => {
const { debugLogger } = await import('@google/gemini-cli-core');
const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
const mockCommand: Command = {
name: 'cyclic-command',
description: '',
subCommands: [],
execute: vi.fn(),
};
mockCommand.subCommands?.push(mockCommand); // Create cycle
commandRegistry.register(mockCommand);
expect(commandRegistry.get('cyclic-command')).toBe(mockCommand);
expect(warnSpy).toHaveBeenCalledWith(
'Command cyclic-command already registered. Skipping.',
);
warnSpy.mockRestore();
});
});
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { MemoryCommand } from './memory.js';
import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionsCommand } from './extensions.js';
import { InitCommand } from './init.js';
import { RestoreCommand } from './restore.js';
import type { Command } from './types.js';
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
constructor() {
this.initialize();
}
initialize() {
this.commands.clear();
this.register(new ExtensionsCommand());
this.register(new RestoreCommand());
this.register(new InitCommand());
this.register(new MemoryCommand());
}
register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
export const commandRegistry = new CommandRegistry();
@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js';
import type { CommandContext } from './types.js';
const mockListExtensions = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
listExtensions: mockListExtensions,
};
});
describe('ExtensionsCommand', () => {
it('should have the correct name', () => {
const command = new ExtensionsCommand();
expect(command.name).toEqual('extensions');
});
it('should have the correct description', () => {
const command = new ExtensionsCommand();
expect(command.description).toEqual('Manage extensions.');
});
it('should have "extensions list" as a subcommand', () => {
const command = new ExtensionsCommand();
expect(command.subCommands.map((c) => c.name)).toContain('extensions list');
});
it('should be a top-level command', () => {
const command = new ExtensionsCommand();
expect(command.topLevel).toBe(true);
});
it('should default to listing extensions', async () => {
const command = new ExtensionsCommand();
const mockConfig = { config: {} } as CommandContext;
const mockExtensions = [{ name: 'ext1' }];
mockListExtensions.mockReturnValue(mockExtensions);
const result = await command.execute(mockConfig, []);
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
});
});
describe('ListExtensionsCommand', () => {
it('should have the correct name', () => {
const command = new ListExtensionsCommand();
expect(command.name).toEqual('extensions list');
});
it('should call listExtensions with the provided config', async () => {
const command = new ListExtensionsCommand();
const mockConfig = { config: {} } as CommandContext;
const mockExtensions = [{ name: 'ext1' }];
mockListExtensions.mockReturnValue(mockExtensions);
const result = await command.execute(mockConfig, []);
expect(result).toEqual({ name: 'extensions list', data: mockExtensions });
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
});
it('should return a message when no extensions are installed', async () => {
const command = new ListExtensionsCommand();
const mockConfig = { config: {} } as CommandContext;
mockListExtensions.mockReturnValue([]);
const result = await command.execute(mockConfig, []);
expect(result).toEqual({
name: 'extensions list',
data: 'No extensions installed.',
});
expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config);
});
});
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [new ListExtensionsCommand()];
readonly topLevel = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(context, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InitCommand } from './init.js';
import {
performInit,
type CommandActionReturn,
type Config,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentExecutor } from '../agent/executor.js';
import { CoderAgentEvent } from '../types.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { createMockConfig } from '../utils/testing_utils.js';
import type { CommandContext } from './types.js';
import { logger } from '../utils/logger.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
performInit: vi.fn(),
};
});
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
vi.mock('../agent/executor.js', () => ({
CoderAgentExecutor: vi.fn().mockImplementation(() => ({
execute: vi.fn(),
})),
}));
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
describe('InitCommand', () => {
let eventBus: ExecutionEventBus;
let command: InitCommand;
let context: CommandContext;
let publishSpy: ReturnType<typeof vi.spyOn>;
let mockExecute: ReturnType<typeof vi.fn>;
const mockWorkspacePath = path.resolve('/tmp');
beforeEach(() => {
process.env['CODER_AGENT_WORKSPACE_PATH'] = mockWorkspacePath;
eventBus = {
publish: vi.fn(),
} as unknown as ExecutionEventBus;
command = new InitCommand();
const mockConfig = createMockConfig({
getModel: () => 'gemini-pro',
});
const mockExecutorInstance = new CoderAgentExecutor();
context = {
config: mockConfig as unknown as Config,
agentExecutor: mockExecutorInstance,
eventBus,
} as CommandContext;
publishSpy = vi.spyOn(eventBus, 'publish');
mockExecute = vi.fn();
vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute);
vi.clearAllMocks();
});
it('has requiresWorkspace set to true', () => {
expect(command.requiresWorkspace).toBe(true);
});
describe('execute', () => {
it('handles info from performInit', async () => {
vi.mocked(performInit).mockReturnValue({
type: 'message',
messageType: 'info',
content: 'GEMINI.md already exists.',
} as CommandActionReturn);
await command.execute(context, []);
expect(logger.info).toHaveBeenCalledWith(
'[EventBus event]: ',
expect.objectContaining({
kind: 'status-update',
status: expect.objectContaining({
state: 'completed',
message: expect.objectContaining({
parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
}),
}),
}),
);
expect(publishSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'status-update',
status: expect.objectContaining({
state: 'completed',
message: expect.objectContaining({
parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }],
}),
}),
}),
);
});
it('handles error from performInit', async () => {
vi.mocked(performInit).mockReturnValue({
type: 'message',
messageType: 'error',
content: 'An error occurred.',
} as CommandActionReturn);
await command.execute(context, []);
expect(publishSpy).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'status-update',
status: expect.objectContaining({
state: 'failed',
message: expect.objectContaining({
parts: [{ kind: 'text', text: 'An error occurred.' }],
}),
}),
}),
);
});
describe('when handling submit_prompt', () => {
beforeEach(() => {
vi.mocked(performInit).mockReturnValue({
type: 'submit_prompt',
content: 'Create a new GEMINI.md file.',
} as CommandActionReturn);
});
it('writes the file and executes the agent', async () => {
await command.execute(context, []);
expect(fs.writeFileSync).toHaveBeenCalledWith(
path.join(mockWorkspacePath, 'GEMINI.md'),
'',
'utf8',
);
expect(mockExecute).toHaveBeenCalled();
});
it('passes autoExecute to the agent executor', async () => {
await command.execute(context, []);
expect(mockExecute).toHaveBeenCalledWith(
expect.objectContaining({
userMessage: expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: 'Create a new GEMINI.md file.',
}),
]),
metadata: {
coderAgent: {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: mockWorkspacePath,
autoExecute: true,
},
},
}),
}),
eventBus,
);
});
});
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentEvent, type AgentSettings } from '../types.js';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { CoderAgentExecutor } from '../agent/executor.js';
import type {
ExecutionEventBus,
RequestContext,
AgentExecutionEvent,
} from '@a2a-js/sdk/server';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
streaming = true;
private handleMessageResult(
result: { content: string; messageType: 'info' | 'error' },
context: CommandContext,
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
): CommandExecutionResponse {
const statusState = result.messageType === 'error' ? 'failed' : 'completed';
const eventType =
result.messageType === 'error'
? CoderAgentEvent.StateChangeEvent
: CoderAgentEvent.TextContentEvent;
const event: AgentExecutionEvent = {
kind: 'status-update',
taskId,
contextId,
status: {
state: statusState,
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: result.content }],
messageId: uuidv4(),
taskId,
contextId,
},
timestamp: new Date().toISOString(),
},
final: true,
metadata: {
coderAgent: { kind: eventType },
model: context.config.getModel(),
},
};
logger.info('[EventBus event]: ', event);
eventBus.publish(event);
return {
name: this.name,
data: result,
};
}
private async handleSubmitPromptResult(
result: { content: unknown },
context: CommandContext,
geminiMdPath: string,
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
): Promise<CommandExecutionResponse> {
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (!context.agentExecutor) {
throw new Error('Agent executor not found in context.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentExecutor = context.agentExecutor as CoderAgentExecutor;
const agentSettings: AgentSettings = {
kind: CoderAgentEvent.StateAgentSettingsEvent,
workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!,
autoExecute: true,
};
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
const promptText = result.content;
const requestContext: RequestContext = {
userMessage: {
kind: 'message',
role: 'user',
parts: [{ kind: 'text', text: promptText }],
messageId: uuidv4(),
taskId,
contextId,
metadata: {
coderAgent: agentSettings,
},
},
taskId,
contextId,
};
// The executor will handle the entire agentic loop, including
// creating the task, streaming responses, and handling tools.
await agentExecutor.execute(requestContext, eventBus);
return {
name: this.name,
data: geminiMdPath,
};
}
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
if (!context.eventBus) {
return {
name: this.name,
data: 'Use executeStream to get streaming results.',
};
}
const geminiMdPath = path.join(
process.env['CODER_AGENT_WORKSPACE_PATH']!,
'GEMINI.md',
);
const result = performInit(fs.existsSync(geminiMdPath));
const taskId = uuidv4();
const contextId = uuidv4();
switch (result.type) {
case 'message':
return this.handleMessageResult(
result,
context,
context.eventBus,
taskId,
contextId,
);
case 'submit_prompt':
return this.handleSubmitPromptResult(
result,
context,
geminiMdPath,
context.eventBus,
taskId,
contextId,
);
default:
throw new Error('Unknown result type from performInit');
}
}
}
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
listMemoryFiles,
refreshMemory,
showMemory,
type Config,
} from '@google/gemini-cli-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ListMemoryCommand,
MemoryCommand,
RefreshMemoryCommand,
ShowMemoryCommand,
} from './memory.js';
import type { CommandContext } from './types.js';
// Mock the core functions
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
showMemory: vi.fn(),
refreshMemory: vi.fn(),
listMemoryFiles: vi.fn(),
};
});
const mockShowMemory = vi.mocked(showMemory);
const mockRefreshMemory = vi.mocked(refreshMemory);
const mockListMemoryFiles = vi.mocked(listMemoryFiles);
describe('a2a-server memory commands', () => {
let mockContext: CommandContext;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {} as unknown as Config;
mockContext = {
config: mockConfig,
};
});
describe('MemoryCommand', () => {
it('delegates to ShowMemoryCommand', async () => {
const command = new MemoryCommand();
mockShowMemory.mockReturnValue({
type: 'message',
messageType: 'info',
content: 'showing memory',
});
const response = await command.execute(mockContext, []);
expect(response.data).toBe('showing memory');
expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
});
});
describe('ShowMemoryCommand', () => {
it('executes showMemory and returns the content', async () => {
const command = new ShowMemoryCommand();
mockShowMemory.mockReturnValue({
type: 'message',
messageType: 'info',
content: 'test memory content',
});
const response = await command.execute(mockContext, []);
expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory show');
expect(response.data).toBe('test memory content');
});
});
describe('RefreshMemoryCommand', () => {
it('executes refreshMemory and returns the content', async () => {
const command = new RefreshMemoryCommand();
mockRefreshMemory.mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'memory refreshed',
});
const response = await command.execute(mockContext, []);
expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory refresh');
expect(response.data).toBe('memory refreshed');
});
});
describe('ListMemoryCommand', () => {
it('executes listMemoryFiles and returns the content', async () => {
const command = new ListMemoryCommand();
mockListMemoryFiles.mockReturnValue({
type: 'message',
messageType: 'info',
content: 'file1.md\nfile2.md',
});
const response = await command.execute(mockContext, []);
expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory list');
expect(response.data).toBe('file1.md\nfile2.md');
});
});
});
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
];
readonly topLevel = true;
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
@@ -0,0 +1,137 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
import type { CommandContext } from './types.js';
import type { Config } from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
beforeEach(() => {
vi.clearAllMocks();
});
const mockPerformRestore = vi.hoisted(() => vi.fn());
const mockLoggerInfo = vi.hoisted(() => vi.fn());
const mockGetCheckpointInfoList = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
performRestore: mockPerformRestore,
getCheckpointInfoList: mockGetCheckpointInfoList,
};
});
const mockFs = vi.hoisted(() => ({
readFile: vi.fn(),
readdir: vi.fn(),
mkdir: vi.fn(),
}));
vi.mock('node:fs/promises', () => mockFs);
vi.mock('../utils/logger.js', () => ({
logger: {
info: mockLoggerInfo,
},
}));
describe('RestoreCommand', () => {
const mockConfig = {
config: createMockConfig() as Config,
git: {},
} as CommandContext;
it('should return error if no checkpoint name is provided', async () => {
const command = new RestoreCommand();
const result = await command.execute(mockConfig, []);
expect(result.data).toEqual({
type: 'message',
messageType: 'error',
content: 'Please provide a checkpoint name to restore.',
});
});
it('should restore a checkpoint when a valid file is provided', async () => {
const command = new RestoreCommand();
const toolCallData = {
toolCall: {
name: 'test-tool',
args: {},
},
history: [],
clientHistory: [],
commitHash: '123',
};
mockFs.readFile.mockResolvedValue(JSON.stringify(toolCallData));
const restoreContent = {
type: 'message',
messageType: 'info',
content: 'Restored',
};
mockPerformRestore.mockReturnValue(
(async function* () {
yield restoreContent;
})(),
);
const result = await command.execute(mockConfig, ['checkpoint1.json']);
expect(result.data).toEqual([restoreContent]);
});
it('should show "file not found" error for a non-existent checkpoint', async () => {
const command = new RestoreCommand();
const error = new Error('File not found');
(error as NodeJS.ErrnoException).code = 'ENOENT';
mockFs.readFile.mockRejectedValue(error);
const result = await command.execute(mockConfig, ['checkpoint2.json']);
expect(result.data).toEqual({
type: 'message',
messageType: 'error',
content: 'File not found: checkpoint2.json',
});
});
it('should handle invalid JSON in checkpoint file', async () => {
const command = new RestoreCommand();
mockFs.readFile.mockResolvedValue('invalid json');
const result = await command.execute(mockConfig, ['checkpoint1.json']);
expect((result.data as { content: string }).content).toContain(
'An unexpected error occurred during restore.',
);
});
});
describe('ListCheckpointsCommand', () => {
const mockConfig = {
config: createMockConfig() as Config,
} as CommandContext;
it('should list all available checkpoints', async () => {
const command = new ListCheckpointsCommand();
const checkpointInfo = [{ file: 'checkpoint1.json', description: 'Test' }];
mockFs.readdir.mockResolvedValue(['checkpoint1.json']);
mockFs.readFile.mockResolvedValue(
JSON.stringify({ toolCall: { name: 'Test', args: {} } }),
);
mockGetCheckpointInfoList.mockReturnValue(checkpointInfo);
const result = await command.execute(mockConfig);
expect((result.data as { content: string }).content).toEqual(
JSON.stringify(checkpointInfo),
);
});
it('should handle errors when listing checkpoints', async () => {
const command = new ListCheckpointsCommand();
mockFs.readdir.mockRejectedValue(new Error('Read error'));
const result = await command.execute(mockConfig);
expect((result.data as { content: string }).content).toContain(
'An unexpected error occurred while listing checkpoints.',
);
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class RestoreCommand implements Command {
readonly name = 'restore';
readonly description =
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
readonly topLevel = true;
readonly requiresWorkspace = true;
readonly subCommands = [new ListCheckpointsCommand()];
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { config, git: gitService } = context;
const argsStr = args.join(' ');
try {
if (!argsStr) {
return {
name: this.name,
data: {
type: 'message',
messageType: 'error',
content: 'Please provide a checkpoint name to restore.',
},
};
}
const selectedFile = argsStr.endsWith('.json')
? argsStr
: `${argsStr}.json`;
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const filePath = path.join(checkpointDir, selectedFile);
let data: string;
try {
data = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
name: this.name,
data: {
type: 'message',
messageType: 'error',
content: `File not found: ${selectedFile}`,
},
};
}
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
if (!parseResult.success) {
return {
name: this.name,
data: {
type: 'message',
messageType: 'error',
content: 'Checkpoint file is invalid or corrupted.',
},
};
}
const restoreResultGenerator = performRestore(
parseResult.data,
gitService,
);
const restoreResult = [];
for await (const result of restoreResultGenerator) {
restoreResult.push(result);
}
return {
name: this.name,
data: restoreResult,
};
} catch {
return {
name: this.name,
data: {
type: 'message',
messageType: 'error',
content: 'An unexpected error occurred during restore.',
},
};
}
}
}
export class ListCheckpointsCommand implements Command {
readonly name = 'restore list';
readonly description = 'Lists all available checkpoints.';
readonly topLevel = false;
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context;
try {
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
await fs.mkdir(checkpointDir, { recursive: true });
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
const checkpointFiles = new Map<string, string>();
for (const file of jsonFiles) {
const filePath = path.join(checkpointDir, file);
const data = await fs.readFile(filePath, 'utf-8');
checkpointFiles.set(file, data);
}
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
return {
name: this.name,
data: {
type: 'message',
messageType: 'info',
content: JSON.stringify(checkpointInfoList),
},
};
} catch {
return {
name: this.name,
data: {
type: 'message',
messageType: 'error',
content: 'An unexpected error occurred while listing checkpoints.',
},
};
}
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server';
import type { Config, GitService } from '@google/gemini-cli-core';
export interface CommandContext {
config: Config;
git?: GitService;
agentExecutor?: AgentExecutor;
eventBus?: ExecutionEventBus;
}
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly topLevel?: boolean;
readonly requiresWorkspace?: boolean;
readonly streaming?: boolean;
execute(
config: CommandContext,
args: string[],
): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
@@ -0,0 +1,575 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as path from 'node:path';
import { loadConfig } from './config.js';
import type { Settings } from './settings.js';
import {
type ExtensionLoader,
getCodeAssistServer,
Config,
ExperimentFlags,
fetchAdminControlsOnce,
type FetchAdminControlsResponse,
AuthType,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
ApprovalMode,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
} from '@google/gemini-cli-core';
import type { AgentSettings } from '../types.js';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
PRIORITY_YOLO_ALLOW_ALL: 998,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: false,
},
},
}),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
}),
startupProfiler: {
flush: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
createPolicyEngineConfig: vi
.fn()
.mockImplementation(
(_settings, mode, _defaultPoliciesDir, _interactive) => ({
rules:
mode === actual.ApprovalMode.YOLO
? [
{
toolName: '*',
decision: actual.PolicyDecision.ALLOW,
priority: actual.PRIORITY_YOLO_ALLOW_ALL,
modes: [actual.ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [
{
toolName: 'read_file',
decision: actual.PolicyDecision.ALLOW,
priority: 1.05,
source: 'Default: read-only.toml',
},
],
checkers: [],
}),
),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
};
});
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe('loadConfig', () => {
const mockSettings = {} as Settings;
const mockExtensionLoader = {} as ExtensionLoader;
const taskId = 'test-task-id';
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('GEMINI_API_KEY', 'test-key');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('admin settings overrides', () => {
it('should not fetch admin controls if experiment is disabled', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).not.toHaveBeenCalled();
});
it('should pass clientName as a2a-server to Config', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
clientName: 'a2a-server',
}),
);
});
describe('when admin controls experiment is enabled', () => {
beforeEach(() => {
// We need to cast to any here to modify the mock implementation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Config as any).mockImplementation((params: unknown) => {
const mockConfig = {
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: vi.fn(),
getExperiments: vi.fn().mockReturnValue({
flags: {
[ExperimentFlags.ENABLE_ADMIN_CONTROLS]: {
boolValue: true,
},
},
}),
getRemoteAdminSettings: vi.fn().mockReturnValue({}),
setRemoteAdminSettings: vi.fn(),
};
return mockConfig;
});
});
it('should fetch admin controls and apply them', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: false,
},
cliFeatureSetting: {
extensionsSetting: {
extensionsEnabled: false,
},
},
strictModeDisabled: false,
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled:
mockAdminSettings.cliFeatureSetting?.extensionsSetting
?.extensionsEnabled,
}),
);
});
it('should treat unset admin settings as false when admin settings are passed', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !false,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
it('should not pass default unset admin settings when no admin settings are present', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {};
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({}));
});
it('should fetch admin controls using the code assist server when available', async () => {
const mockAdminSettings: FetchAdminControlsResponse = {
mcpSetting: {
mcpEnabled: true,
},
strictModeDisabled: true,
};
const mockCodeAssistServer = { projectId: 'test-project' };
vi.mocked(getCodeAssistServer).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCodeAssistServer as any,
);
vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(fetchAdminControlsOnce).toHaveBeenCalledWith(
mockCodeAssistServer,
true,
);
expect(Config).toHaveBeenLastCalledWith(
expect.objectContaining({
disableYoloMode: !mockAdminSettings.strictModeDisabled,
mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled,
extensionsEnabled: undefined,
}),
);
});
});
});
it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => {
const testPath = '/tmp/ignore';
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
testPath,
]);
});
it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => {
const testPath = '/settings/ignore';
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [testPath],
},
};
const config = await loadConfig(settings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
testPath,
]);
});
it('should merge customIgnoreFilePaths from settings and env var', async () => {
const envPath = '/env/ignore';
const settingsPath = '/settings/ignore';
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath);
const settings: Settings = {
fileFiltering: {
customIgnoreFilePaths: [settingsPath],
},
};
const config = await loadConfig(settings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([
settingsPath,
envPath,
]);
});
it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => {
const paths = ['/path/one', '/path/two'];
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter));
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths);
});
it('should have empty customIgnoreFilePaths when both are missing', async () => {
const config = await loadConfig(mockSettings, mockExtensionLoader, taskId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
});
describe('policy engine configuration', () => {
it('should map tool settings into policySettings', async () => {
const settings: Settings = {
tools: {
allowed: ['v2-allowed'],
exclude: ['v2-exclude'],
core: ['v2-core'],
},
mcpServers: {
test: { command: 'test', args: [] },
},
policyPaths: ['/path/to/policy'],
adminPolicyPaths: ['/path/to/admin/policy'],
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
core: ['v2-core'],
exclude: ['v2-exclude'],
allowed: ['v2-allowed'],
},
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
});
describe('tool configuration', () => {
it('should pass V2 tools.allowed to Config properly', async () => {
const settings: Settings = {
tools: {
allowed: ['shell', 'fetch'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
allowedTools: ['shell', 'fetch'],
}),
);
});
it('should pass enableAgents to Config constructor', async () => {
const settings: Settings = {
experimental: {
enableAgents: false,
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: false,
}),
);
});
it('should default enableAgents to true when not provided', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: true,
}),
);
});
describe('interactivity', () => {
it('should always set interactive true', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
}),
);
});
it('should set enableInteractiveShell based on headless mode', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableInteractiveShell: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableInteractiveShell: false,
}),
);
});
});
describe('YOLO mode', () => {
it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'true');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'yolo',
policyEngineConfig: expect.objectContaining({
rules: expect.arrayContaining([
expect.objectContaining({
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: ['yolo'],
allowRedirection: true,
}),
]),
}),
}),
);
});
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'default',
policyEngineConfig: expect.objectContaining({
rules: expect.arrayContaining([
expect.objectContaining({
toolName: 'read_file',
decision: PolicyDecision.ALLOW,
}),
]),
}),
}),
);
});
});
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
);
});
});
});
});
describe('setIsTrusted', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should return true when GEMINI_FOLDER_TRUST env var is true', async () => {
vi.stubEnv('GEMINI_FOLDER_TRUST', 'true');
const { setIsTrusted } = await import('./config.js');
expect(setIsTrusted(undefined)).toBe(true);
expect(setIsTrusted({ isTrusted: false } as AgentSettings)).toBe(true);
});
it('should return false when GEMINI_FOLDER_TRUST env var is false', async () => {
vi.stubEnv('GEMINI_FOLDER_TRUST', 'false');
const { setIsTrusted } = await import('./config.js');
expect(setIsTrusted(undefined)).toBe(false);
expect(setIsTrusted({ isTrusted: true } as AgentSettings)).toBe(false);
});
it('should fallback to agentSettings.isTrusted if env var is undefined', async () => {
const { setIsTrusted } = await import('./config.js');
expect(setIsTrusted({ isTrusted: true } as AgentSettings)).toBe(true);
expect(setIsTrusted({ isTrusted: false } as AgentSettings)).toBe(false);
expect(setIsTrusted(undefined)).toBe(false);
});
});
+318
View File
@@ -0,0 +1,318 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import {
AuthType,
Config,
ApprovalMode,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
startupProfiler,
PREVIEW_GEMINI_MODEL,
homedir,
GitService,
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
createPolicyEngineConfig,
type PolicySettings,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
import type { Settings } from './settings.js';
import { type AgentSettings, CoderAgentEvent } from '../types.js';
const INITIAL_FOLDER_TRUST = process.env['GEMINI_FOLDER_TRUST'];
export async function loadConfig(
settings: Settings,
extensionLoader: ExtensionLoader,
taskId: string,
trusted: boolean = false,
): Promise<Config> {
const workspaceDir = process.cwd();
const folderTrust =
settings.folderTrust === true ||
process.env['GEMINI_FOLDER_TRUST'] === 'true';
let checkpointing = process.env['CHECKPOINTING']
? process.env['CHECKPOINTING'] === 'true'
: settings.checkpointing?.enabled;
if (checkpointing) {
if (!(await GitService.verifyGitAvailability())) {
logger.warn(
'[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.',
);
checkpointing = false;
}
}
const approvalMode =
process.env['GEMINI_YOLO_MODE'] === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
const policySettings: PolicySettings = {
mcpServers: settings.mcpServers,
tools: {
core: settings.tools?.core,
exclude: settings.tools?.exclude,
allowed: settings.tools?.allowed,
},
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
};
const policyEngineConfig = await createPolicyEngineConfig(
policySettings,
approvalMode,
undefined,
true,
);
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
model: PREVIEW_GEMINI_MODEL,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
targetDir: workspaceDir, // Or a specific directory the agent operates on
debugMode: process.env['DEBUG'] === 'true' || false,
question: '', // Not used in server mode directly like CLI
coreTools: settings.tools?.core || undefined,
excludeTools: settings.tools?.exclude || undefined,
allowedTools: settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode,
policyEngineConfig,
mcpServers: settings.mcpServers,
cwd: workspaceDir,
telemetry: {
enabled: settings.telemetry?.enabled,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
target: settings.telemetry?.target as TelemetryTarget,
otlpEndpoint:
process.env['OTEL_EXPORTER_OTLP_ENDPOINT'] ??
settings.telemetry?.otlpEndpoint,
logPrompts: settings.telemetry?.logPrompts,
},
// Git-aware file filtering settings
fileFiltering: {
respectGitIgnore: settings.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore,
enableRecursiveFileSearch:
settings.fileFiltering?.enableRecursiveFileSearch,
customIgnoreFilePaths: [
...(settings.fileFiltering?.customIgnoreFilePaths || []),
...(process.env['CUSTOM_IGNORE_FILE_PATHS']
? process.env['CUSTOM_IGNORE_FILE_PATHS'].split(path.delimiter)
: []),
],
},
ideMode: false,
folderTrust,
trustedFolder: trusted,
extensionLoader,
checkpointing,
interactive: true,
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? true,
};
// Set an initial config to use to get a code assist server.
// This is needed to fetch admin controls.
const initialConfig = new Config({
...configParams,
});
const codeAssistServer = getCodeAssistServer(initialConfig);
const adminControlsEnabled =
initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]
?.boolValue ?? false;
// Initialize final config parameters to the previous parameters.
// If no admin controls are needed, these will be used as-is for the final
// config.
const finalConfigParams = { ...configParams };
if (adminControlsEnabled) {
const adminSettings = await fetchAdminControlsOnce(
codeAssistServer,
adminControlsEnabled,
);
// Admin settings are able to be undefined if unset, but if any are present,
// we should initialize them all.
// If any are present, undefined settings should be treated as if they were
// set to false.
// If NONE are present, disregard admin settings entirely, and pass the
// final config as is.
if (Object.keys(adminSettings).length !== 0) {
finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled;
finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled;
finalConfigParams.extensionsEnabled =
adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled;
}
}
const config = new Config(finalConfigParams);
// Needed to initialize ToolRegistry, and git checkpointing if enabled
await config.initialize();
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
return config;
}
export function setIsTrusted(
agentSettings: AgentSettings | undefined,
): boolean {
if (INITIAL_FOLDER_TRUST !== undefined) {
return INITIAL_FOLDER_TRUST === 'true';
}
return !!agentSettings?.isTrusted;
}
export function setTargetDir(agentSettings: AgentSettings | undefined): string {
const originalCWD = process.cwd();
const targetDir =
process.env['CODER_AGENT_WORKSPACE_PATH'] ??
(agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent
? agentSettings.workspacePath
: undefined);
if (!targetDir) {
return originalCWD;
}
logger.info(
`[CoderAgentExecutor] Overriding workspace path to: ${targetDir}`,
);
try {
const resolvedPath = path.resolve(targetDir);
process.chdir(resolvedPath);
return resolvedPath;
} catch (e) {
logger.error(
`[CoderAgentExecutor] Error resolving workspace path: ${e}, returning original os.cwd()`,
);
return originalCWD;
}
}
export function loadEnvironment(): void {
const envFilePath = findEnvFile(process.cwd());
if (envFilePath) {
dotenv.config({ path: envFilePath, override: true });
}
}
function findEnvFile(startDir: string): string | null {
let currentDir = path.resolve(startDir);
while (true) {
// prefer gemini-specific .env under GEMINI_DIR
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
if (fs.existsSync(geminiEnvPath)) {
return geminiEnvPath;
}
const envPath = path.join(currentDir, '.env');
if (fs.existsSync(envPath)) {
return envPath;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir || !parentDir) {
// check .env under home as fallback, again preferring gemini-specific .env
const homeGeminiEnvPath = path.join(process.cwd(), GEMINI_DIR, '.env');
if (fs.existsSync(homeGeminiEnvPath)) {
return homeGeminiEnvPath;
}
const homeEnvPath = path.join(homedir(), '.env');
if (fs.existsSync(homeEnvPath)) {
return homeEnvPath;
}
return null;
}
currentDir = parentDir;
}
}
async function refreshAuthentication(
config: Config,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
);
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
);
}
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
);
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
} else if (process.env['GEMINI_API_KEY']) {
logger.info(`[${logPrefix}] Using Gemini API Key`);
await config.refreshAuth(AuthType.USE_GEMINI);
} else {
const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
}
+154
View File
@@ -0,0 +1,154 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Copied exactly from packages/cli/src/config/extension.ts, last PR #1026
import {
GEMINI_DIR,
type MCPServerConfig,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
homedir,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { logger } from '../utils/logger.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
/**
* Extension definition as written to disk in gemini-extension.json files.
* This should *not* be referenced outside of the logic for reading files.
* If information is required for manipulating extensions (load, unload, update)
* outside of the loading process that data needs to be stored on the
* GeminiCLIExtension class defined in Core.
*/
interface ExtensionConfig {
name: string;
version: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
}
export function loadExtensions(workspaceDir: string): GeminiCLIExtension[] {
const allExtensions = [
...loadExtensionsFromDir(workspaceDir),
...loadExtensionsFromDir(homedir()),
];
const uniqueExtensions: GeminiCLIExtension[] = [];
const seenNames = new Set<string>();
for (const extension of allExtensions) {
if (!seenNames.has(extension.name)) {
logger.info(
`Loading extension: ${extension.name} (version: ${extension.version})`,
);
uniqueExtensions.push(extension);
seenNames.add(extension.name);
}
}
return uniqueExtensions;
}
function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] {
const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME);
if (!fs.existsSync(extensionsDir)) {
return [];
}
const extensions: GeminiCLIExtension[] = [];
for (const subdir of fs.readdirSync(extensionsDir)) {
const extensionDir = path.join(extensionsDir, subdir);
const extension = loadExtension(extensionDir);
if (extension != null) {
extensions.push(extension);
}
}
return extensions;
}
function loadExtension(extensionDir: string): GeminiCLIExtension | null {
if (!fs.statSync(extensionDir).isDirectory()) {
logger.error(
`Warning: unexpected file ${extensionDir} in extensions directory.`,
);
return null;
}
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (!fs.existsSync(configFilePath)) {
logger.error(
`Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`,
);
return null;
}
try {
const configContent = fs.readFileSync(configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = JSON.parse(configContent) as ExtensionConfig;
if (!config.name || !config.version) {
logger.error(
`Invalid extension config in ${configFilePath}: missing name or version.`,
);
return null;
}
const installMetadata = loadInstallMetadata(extensionDir);
const contextFiles = getContextFileNames(config)
.map((contextFileName) => path.join(extensionDir, contextFileName))
.filter((contextFilePath) => fs.existsSync(contextFilePath));
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return {
name: config.name,
version: config.version,
path: extensionDir,
contextFiles,
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: true, // Barring any other signals extensions should be considered Active.
} as GeminiCLIExtension;
} catch (e) {
logger.error(
`Warning: error parsing extension config in ${configFilePath}: ${e}`,
);
return null;
}
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
return ['GEMINI.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
}
return config.contextFileName;
}
export function loadInstallMetadata(
extensionDir: string,
): ExtensionInstallMetadata | undefined {
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
try {
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (e) {
logger.warn(
`Failed to load or parse extension install metadata at ${metadataFilePath}: ${e}`,
);
return undefined;
}
}
@@ -0,0 +1,235 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
const mocks = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
return {
suffix,
};
});
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
const path = await import('node:path');
return {
...actual,
homedir: () => path.join(actual.tmpdir(), `gemini-home-${mocks.suffix}`),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const path = await import('node:path');
const os = await import('node:os');
return {
...actual,
GEMINI_DIR: '.gemini',
debugLogger: {
error: vi.fn(),
},
getErrorMessage: (error: unknown) => String(error),
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
checkPathTrust: vi.fn(() => ({ isTrusted: false })),
isHeadlessMode: vi.fn(() => true),
};
});
describe('loadSettings', () => {
const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`);
const mockWorkspaceDir = path.join(
os.tmpdir(),
`gemini-workspace-${mocks.suffix}`,
);
const mockGeminiHomeDir = path.join(mockHomeDir, '.gemini');
const mockGeminiWorkspaceDir = path.join(mockWorkspaceDir, '.gemini');
beforeEach(() => {
vi.clearAllMocks();
// Create the directories using the real fs
if (!fs.existsSync(mockGeminiHomeDir)) {
fs.mkdirSync(mockGeminiHomeDir, { recursive: true });
}
if (!fs.existsSync(mockGeminiWorkspaceDir)) {
fs.mkdirSync(mockGeminiWorkspaceDir, { recursive: true });
}
// Clean up settings files before each test
if (fs.existsSync(USER_SETTINGS_PATH)) {
fs.rmSync(USER_SETTINGS_PATH);
}
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
if (fs.existsSync(workspaceSettingsPath)) {
fs.rmSync(workspaceSettingsPath);
}
});
afterEach(() => {
try {
if (fs.existsSync(mockHomeDir)) {
fs.rmSync(mockHomeDir, { recursive: true, force: true });
}
if (fs.existsSync(mockWorkspaceDir)) {
fs.rmSync(mockWorkspaceDir, { recursive: true, force: true });
}
} catch (e) {
debugLogger.error('Failed to cleanup temp dirs', e);
}
vi.restoreAllMocks();
});
it('should load other top-level settings correctly', () => {
const settings = {
showMemoryUsage: true,
tools: {
core: ['tool1', 'tool2'],
},
mcpServers: {
server1: {
command: 'cmd',
args: ['arg'],
},
},
fileFiltering: {
respectGitIgnore: true,
},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
expect(result.tools?.core).toEqual(['tool1', 'tool2']);
expect(result.mcpServers).toHaveProperty('server1');
expect(result.fileFiltering?.respectGitIgnore).toBe(true);
});
it('should load experimental settings correctly', () => {
const settings = {
experimental: {
enableAgents: true,
},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
const result = loadSettings(mockWorkspaceDir);
expect(result.experimental?.enableAgents).toBe(true);
});
it('should overwrite top-level settings from workspace (shallow merge)', () => {
const userSettings = {
showMemoryUsage: false,
fileFiltering: {
respectGitIgnore: true,
enableRecursiveFileSearch: true,
},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = {
showMemoryUsage: true,
fileFiltering: {
respectGitIgnore: false,
},
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
const result = loadSettings(mockWorkspaceDir, true);
// Primitive value overwritten
expect(result.showMemoryUsage).toBe(true);
// Object value completely replaced (shallow merge behavior)
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
});
describe('security', () => {
it('should NOT load workspace settings if workspace is NOT trusted', () => {
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
// checkPathTrust is mocked to return isTrusted: false by default
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(false);
});
it('should load workspace settings if workspace IS trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
});
it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = {
adminPolicyPaths: ['/trusted/admin'],
policyPaths: ['/trusted/user'],
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = {
adminPolicyPaths: ['./malicious/admin'],
policyPaths: ['./malicious/user'],
showMemoryUsage: true,
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
expect(result.policyPaths).toEqual(['/trusted/user']);
});
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
type MCPServerConfig,
debugLogger,
GEMINI_DIR,
getErrorMessage,
type TelemetrySettings,
homedir,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR);
export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json');
// TODO: Ensure full compatibility with V2 nested settings structure (settings.schema.json).
// This involves updating the interface and implementing migration logic to support legacy V1 (flat) settings,
// similar to how packages/cli/src/config/settings.ts handles it.
export interface Settings {
mcpServers?: Record<string, MCPServerConfig>;
tools?: {
allowed?: string[];
exclude?: string[];
core?: string[];
};
telemetry?: TelemetrySettings;
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
folderTrust?: boolean;
general?: {
previewFeatures?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
respectGitIgnore?: boolean;
respectGeminiIgnore?: boolean;
enableRecursiveFileSearch?: boolean;
customIgnoreFilePaths?: string[];
};
experimental?: {
enableAgents?: boolean;
};
policyPaths?: string[];
adminPolicyPaths?: string[];
}
export interface SettingsError {
message: string;
path: string;
}
export interface CheckpointingSettings {
enabled?: boolean;
}
/**
* Loads settings from user and workspace directories.
* Project settings override user settings if the workspace is trusted.
*
* How is it different to gemini-cli/cli: Returns already merged settings rather
* than `LoadedSettings` (unnecessary since we are not modifying users
* settings.json).
*/
export function loadSettings(
workspaceDir: string,
isTrustedOverride?: boolean,
): Settings {
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
// Load user settings
try {
if (fs.existsSync(USER_SETTINGS_PATH)) {
const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedUserSettings = JSON.parse(
stripJsonComments(userContent),
) as Settings;
userSettings = resolveEnvVarsInObject(parsedUserSettings);
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: USER_SETTINGS_PATH,
});
}
let isTrusted = isTrustedOverride;
if (isTrusted === undefined) {
const { isTrusted: trustResult } = checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: userSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
isTrusted = trustResult ?? false;
}
const workspaceSettingsPath = path.join(
workspaceDir,
GEMINI_DIR,
'settings.json',
);
// Load workspace settings only if trusted
if (isTrusted) {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
}
if (settingsErrors.length > 0) {
debugLogger.error('Errors loading settings:');
for (const error of settingsErrors) {
debugLogger.error(` Path: ${error.path}`);
debugLogger.error(` Message: ${error.message}`);
}
}
// If there are overlapping keys, the values of workspaceSettings will
// override values from userSettings
const mergedSettings = {
...userSettings,
...workspaceSettings,
};
// Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
// configuration and cannot be overridden by workspace-level settings, even if the
// workspace is trusted.
mergedSettings.policyPaths = userSettings.policyPaths;
mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
return mergedSettings;
}
function resolveEnvVarsInString(value: string): string {
const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
return value.replace(
envVarRegex,
(match: string, varName1: string, varName2: string) => {
const varName = varName1 || varName2;
const envValue = process?.env?.[varName];
if (typeof envValue === 'string') {
return envValue;
}
return match;
},
);
}
function resolveEnvVarsInObject<T>(obj: T): T {
if (
obj === null ||
obj === undefined ||
typeof obj === 'boolean' ||
typeof obj === 'number'
) {
return obj;
}
if (typeof obj === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return resolveEnvVarsInString(obj) as unknown as T;
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return
return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T;
}
if (typeof obj === 'object') {
const newObj = { ...obj } as T;
for (const key in newObj) {
if (Object.prototype.hasOwnProperty.call(newObj, key)) {
newObj[key] = resolveEnvVarsInObject(newObj[key]);
}
}
return newObj;
}
return obj;
}
File diff suppressed because it is too large Load Diff
+413
View File
@@ -0,0 +1,413 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import express, { type Request } from 'express';
import type { AgentCard, Message } from '@a2a-js/sdk';
import {
type TaskStore,
DefaultRequestHandler,
InMemoryTaskStore,
DefaultExecutionEventBus,
type AgentExecutionEvent,
UnauthenticatedUser,
} from '@a2a-js/sdk/server';
import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type { AgentSettings } from '../types.js';
import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js';
import { CoderAgentExecutor } from '../agent/executor.js';
import { requestStorage } from './requestStorage.js';
import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { loadExtensions } from '../config/extension.js';
import { commandRegistry } from '../commands/command-registry.js';
import {
debugLogger,
SimpleExtensionLoader,
GitService,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
type CommandResponse = {
name: string;
description: string;
arguments: CommandArgument[];
subCommands: CommandResponse[];
};
const coderAgentCard: AgentCard = {
name: 'Gemini SDLC Agent',
description:
'An agent that generates code based on natural language instructions and streams file outputs.',
url: 'http://localhost:41242/',
provider: {
organization: 'Google',
url: 'https://google.com',
},
protocolVersion: '0.3.0',
version: '0.0.2', // Incremented version
capabilities: {
streaming: true,
pushNotifications: false,
stateTransitionHistory: true,
},
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
basicAuth: {
type: 'http',
scheme: 'basic',
},
},
security: [{ bearerAuth: [] }, { basicAuth: [] }],
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
{
id: 'code_generation',
name: 'Code Generation',
description:
'Generates code snippets or complete files based on user requests, streaming the results.',
tags: ['code', 'development', 'programming'],
examples: [
'Write a python function to calculate fibonacci numbers.',
'Create an HTML file with a basic button that alerts "Hello!" when clicked.',
],
inputModes: ['text'],
outputModes: ['text'],
},
],
supportsAuthenticatedExtendedCard: false,
};
export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}
const customUserBuilder: UserBuilder = async (req: Request) => {
const auth = req.headers['authorization'];
if (auth) {
const scheme = auth.split(' ')[0];
logger.info(
`[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
);
}
if (!auth) return new UnauthenticatedUser();
// 1. Bearer Auth
if (auth.startsWith('Bearer ')) {
const token = auth.substring(7);
if (token === 'valid-token') {
return { userName: 'bearer-user', isAuthenticated: true };
}
}
// 2. Basic Auth
if (auth.startsWith('Basic ')) {
const credentials = Buffer.from(auth.substring(6), 'base64').toString();
if (credentials === 'admin:password') {
return { userName: 'basic-user', isAuthenticated: true };
}
}
return new UnauthenticatedUser();
};
async function handleExecuteCommand(
req: express.Request,
res: express.Response,
context: {
config: Awaited<ReturnType<typeof loadConfig>>;
git: GitService | undefined;
agentExecutor: CoderAgentExecutor;
},
) {
logger.info('[CoreAgent] Received /executeCommand request: ', req.body);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { command, args } = req.body;
try {
if (typeof command !== 'string') {
return res.status(400).json({ error: 'Invalid "command" field.' });
}
if (args && !Array.isArray(args)) {
return res.status(400).json({ error: '"args" field must be an array.' });
}
const commandToExecute = commandRegistry.get(command);
if (commandToExecute?.requiresWorkspace) {
if (!process.env['CODER_AGENT_WORKSPACE_PATH']) {
return res.status(400).json({
error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`,
});
}
}
if (!commandToExecute) {
return res.status(404).json({ error: `Command not found: ${command}` });
}
if (commandToExecute.streaming) {
const eventBus = new DefaultExecutionEventBus();
res.setHeader('Content-Type', 'text/event-stream');
const eventHandler = (event: AgentExecutionEvent) => {
const jsonRpcResponse = {
jsonrpc: '2.0',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
id: 'taskId' in event ? event.taskId : (event as Message).messageId,
result: event,
};
res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`);
};
eventBus.on('event', eventHandler);
await commandToExecute.execute({ ...context, eventBus }, args ?? []);
eventBus.off('event', eventHandler);
eventBus.finished();
return res.end(); // Explicit return for streaming path
} else {
const result = await commandToExecute.execute(context, args ?? []);
logger.info('[CoreAgent] Sending /executeCommand response: ', result);
return res.status(200).json(result);
}
} catch (e) {
logger.error(
`Error executing /executeCommand: ${command} with args: ${JSON.stringify(
args,
)}`,
e,
);
const errorMessage =
e instanceof Error ? e.message : 'Unknown error executing command';
return res.status(500).json({ error: errorMessage });
}
}
export async function createApp() {
try {
// Load the server configuration once on startup.
const workspaceRoot = setTargetDir(undefined);
loadEnvironment();
// Use a temporary settings load to check if folder trust is enabled.
// This is similar to how the CLI handles the initial trust check.
const initialSettings = loadSettings(workspaceRoot, false);
const { isTrusted } = checkPathTrust({
path: workspaceRoot,
isFolderTrustEnabled: initialSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
const extensions = loadExtensions(workspaceRoot);
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
isTrusted ?? false,
);
let git: GitService | undefined;
if (config.getCheckpointingEnabled()) {
git = new GitService(config.getTargetDir(), config.storage);
await git.initialize();
}
// loadEnvironment() is called within getConfig now
const bucketName = process.env['GCS_BUCKET_NAME'];
let taskStoreForExecutor: TaskStore;
let taskStoreForHandler: TaskStore;
if (bucketName) {
logger.info(`Using GCSTaskStore with bucket: ${bucketName}`);
const gcsTaskStore = new GCSTaskStore(bucketName);
taskStoreForExecutor = gcsTaskStore;
taskStoreForHandler = new NoOpTaskStore(gcsTaskStore);
} else {
logger.info('Using InMemoryTaskStore');
const inMemoryTaskStore = new InMemoryTaskStore();
taskStoreForExecutor = inMemoryTaskStore;
taskStoreForHandler = inMemoryTaskStore;
}
const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor);
const context = { config, git, agentExecutor };
const requestHandler = new DefaultRequestHandler(
coderAgentCard,
taskStoreForHandler,
agentExecutor,
);
let expressApp = express();
expressApp.use((req, res, next) => {
requestStorage.run({ req }, next);
});
const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());
expressApp.post('/tasks', async (req, res) => {
try {
const taskId = uuidv4();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const agentSettings = req.body.agentSettings as
| AgentSettings
| undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const contextId = req.body.contextId || uuidv4();
const wrapper = await agentExecutor.createTask(
taskId,
contextId,
agentSettings,
);
await taskStoreForExecutor.save(wrapper.toSDKTask());
res.status(201).json(wrapper.id);
} catch (error) {
logger.error('[CoreAgent] Error creating task:', error);
const errorMessage =
error instanceof Error
? error.message
: 'Unknown error creating task';
res.status(500).send({ error: errorMessage });
}
});
expressApp.post('/executeCommand', (req, res) => {
void handleExecuteCommand(req, res, context);
});
expressApp.get('/listCommands', (req, res) => {
try {
const transformCommand = (
command: Command,
visited: string[],
): CommandResponse | undefined => {
const commandName = command.name;
if (visited.includes(commandName)) {
debugLogger.warn(
`Command ${commandName} already inserted in the response, skipping`,
);
return undefined;
}
return {
name: command.name,
description: command.description,
arguments: command.arguments ?? [],
subCommands: (command.subCommands ?? [])
.map((subCommand) =>
transformCommand(subCommand, visited.concat(commandName)),
)
.filter(
(subCommand): subCommand is CommandResponse => !!subCommand,
),
};
};
const commands = commandRegistry
.getAllCommands()
.filter((command) => command.topLevel)
.map((command) => transformCommand(command, []));
return res.status(200).json({ commands });
} catch (e) {
logger.error('Error executing /listCommands:', e);
const errorMessage =
e instanceof Error ? e.message : 'Unknown error listing commands';
return res.status(500).json({ error: errorMessage });
}
});
expressApp.get('/tasks/metadata', async (req, res) => {
// This endpoint is only meaningful if the task store is in-memory.
if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) {
res.status(501).send({
error:
'Listing all task metadata is only supported when using InMemoryTaskStore.',
});
}
try {
const wrappers = agentExecutor.getAllTasks();
if (wrappers && wrappers.length > 0) {
const tasksMetadata = await Promise.all(
wrappers.map((wrapper) => wrapper.task.getMetadata()),
);
res.status(200).json(tasksMetadata);
} else {
res.status(204).send();
}
} catch (error) {
logger.error('[CoreAgent] Error getting all task metadata:', error);
const errorMessage =
error instanceof Error
? error.message
: 'Unknown error getting task metadata';
res.status(500).send({ error: errorMessage });
}
});
expressApp.get('/tasks/:taskId/metadata', async (req, res) => {
const taskId = req.params.taskId;
let wrapper = agentExecutor.getTask(taskId);
if (!wrapper) {
const sdkTask = await taskStoreForExecutor.load(taskId);
if (sdkTask) {
wrapper = await agentExecutor.reconstruct(sdkTask);
}
}
if (!wrapper) {
res.status(404).send({ error: 'Task not found' });
return;
}
res.json({ metadata: await wrapper.task.getMetadata() });
});
return expressApp;
} catch (error) {
logger.error('[CoreAgent] Error during startup:', error);
process.exit(1);
}
}
export async function main() {
try {
const expressApp = await createApp();
const port = Number(process.env['CODER_AGENT_PORT'] || 0);
const server = expressApp.listen(port, 'localhost', () => {
const address = server.address();
let actualPort;
if (process.env['CODER_AGENT_PORT']) {
actualPort = process.env['CODER_AGENT_PORT'];
} else if (address && typeof address !== 'string') {
actualPort = address.port;
} else {
throw new Error('[Core Agent] Could not find port number.');
}
updateCoderAgentCardUrl(Number(actualPort));
logger.info(
`[CoreAgent] Agent Server started on http://localhost:${actualPort}`,
);
logger.info(
`[CoreAgent] Agent Card: http://localhost:${actualPort}/.well-known/agent-card.json`,
);
logger.info('[CoreAgent] Press Ctrl+C to stop the server');
});
} catch (error) {
logger.error('[CoreAgent] Error during startup:', error);
process.exit(1);
}
}
@@ -0,0 +1,162 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import request from 'supertest';
import type express from 'express';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { createApp, updateCoderAgentCardUrl } from './app.js';
import type { TaskMetadata } from '../types.js';
import { createMockConfig } from '../utils/testing_utils.js';
import { debugLogger, type Config } from '@google/gemini-cli-core';
// Mock the logger to avoid polluting test output
// Comment out to help debug
vi.mock('../utils/logger.js', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// Mock Task.create to avoid its complex setup
vi.mock('../agent/task.js', () => {
class MockTask {
id: string;
contextId: string;
taskState = 'submitted';
config = {
getContentGeneratorConfig: vi
.fn()
.mockReturnValue({ model: 'gemini-pro' }),
};
geminiClient = {
initialize: vi.fn().mockResolvedValue(undefined),
};
constructor(id: string, contextId: string) {
this.id = id;
this.contextId = contextId;
}
static create = vi
.fn()
.mockImplementation((id, contextId) =>
Promise.resolve(new MockTask(id, contextId)),
);
getMetadata = vi.fn().mockImplementation(async () => ({
id: this.id,
contextId: this.contextId,
taskState: this.taskState,
model: 'gemini-pro',
mcpServers: [],
availableTools: [],
}));
}
return { Task: MockTask };
});
vi.mock('../config/config.js', async () => {
const actual = await vi.importActual('../config/config.js');
return {
...actual,
loadConfig: vi
.fn()
.mockImplementation(async () => createMockConfig({}) as Config),
};
});
describe('Agent Server Endpoints', () => {
let app: express.Express;
let server: Server;
let testWorkspace: string;
const createTask = (contextId: string) =>
request(app)
.post('/tasks')
.send({
contextId,
agentSettings: {
kind: 'agent-settings',
workspacePath: testWorkspace,
},
})
.set('Content-Type', 'application/json');
beforeAll(async () => {
// Create a unique temporary directory for the workspace to avoid conflicts
testWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-agent-test-'),
);
app = await createApp();
await new Promise<void>((resolve) => {
server = app.listen(0, () => {
const port = (server.address() as AddressInfo).port;
updateCoderAgentCardUrl(port);
resolve();
});
});
});
afterAll(async () => {
if (server) {
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) return reject(err);
resolve();
});
});
}
if (testWorkspace) {
try {
fs.rmSync(testWorkspace, { recursive: true, force: true });
} catch (e) {
debugLogger.warn(`Could not remove temp dir '${testWorkspace}':`, e);
}
}
});
it('should create a new task via POST /tasks', async () => {
const response = await createTask('test-context');
expect(response.status).toBe(201);
expect(response.body).toBeTypeOf('string'); // Should return the task ID
}, 7000);
it('should get metadata for a specific task via GET /tasks/:taskId/metadata', async () => {
const createResponse = await createTask('test-context-2');
const taskId = createResponse.body;
const response = await request(app).get(`/tasks/${taskId}/metadata`);
expect(response.status).toBe(200);
expect(response.body.metadata.id).toBe(taskId);
}, 6000);
it('should get metadata for all tasks via GET /tasks/metadata', async () => {
const createResponse = await createTask('test-context-3');
const taskId = createResponse.body;
const response = await request(app).get('/tasks/metadata');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const taskMetadata = response.body.find(
(m: TaskMetadata) => m.id === taskId,
);
expect(taskMetadata).toBeDefined();
});
it('should return 404 for a non-existent task', async () => {
const response = await request(app).get('/tasks/fake-task/metadata');
expect(response.status).toBe(404);
});
it('should return agent metadata via GET /.well-known/agent-card.json', async () => {
const response = await request(app).get('/.well-known/agent-card.json');
const port = (server.address() as AddressInfo).port;
expect(response.status).toBe(200);
expect(response.body.name).toBe('Gemini SDLC Agent');
expect(response.body.url).toBe(`http://localhost:${port}/`);
});
});
@@ -0,0 +1,10 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestStorage = new AsyncLocalStorage<{ req: express.Request }>();
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as url from 'node:url';
import * as path from 'node:path';
import { logger } from '../utils/logger.js';
import { main } from './app.js';
// Check if the module is the main script being run
const isMainModule =
path.basename(process.argv[1]) ===
path.basename(url.fileURLToPath(import.meta.url));
if (
import.meta.url.startsWith('file:') &&
isMainModule &&
process.env['NODE_ENV'] !== 'test'
) {
process.on('uncaughtException', (error) => {
logger.error('Unhandled exception:', error);
process.exit(1);
});
main().catch((error) => {
logger.error('[CoreAgent] Unhandled error in main:', error);
process.exit(1);
});
}
+9
View File
@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export * from './agent/executor.js';
export * from './http/app.js';
export * from './types.js';
@@ -0,0 +1,446 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Storage } from '@google-cloud/storage';
import * as fse from 'fs-extra';
import * as tar from 'tar';
import { gzipSync, gunzipSync } from 'node:zlib';
import { v4 as uuidv4 } from 'uuid';
import type { Task as SDKTask } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import {
describe,
it,
expect,
beforeEach,
vi,
type Mocked,
type MockedClass,
type Mock,
} from 'vitest';
import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
import { logger } from '../utils/logger.js';
import * as configModule from '../config/config.js';
import { getPersistedState, METADATA_KEY } from '../types.js';
// Mock dependencies
const fsMocks = vi.hoisted(() => ({
readdir: vi.fn(),
createReadStream: vi.fn(),
}));
vi.mock('@google-cloud/storage');
vi.mock('fs-extra', () => ({
pathExists: vi.fn(),
readdir: vi.fn(),
remove: vi.fn(),
ensureDir: vi.fn(),
createReadStream: vi.fn(),
}));
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
promises: {
...actual.promises,
readdir: fsMocks.readdir,
},
createReadStream: fsMocks.createReadStream,
};
});
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
promises: {
...actual.promises,
readdir: fsMocks.readdir,
},
createReadStream: fsMocks.createReadStream,
};
});
vi.mock('tar', async () => {
const actualFs = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
c: vi.fn(({ file }) => {
if (file) {
actualFs.writeFileSync(file, Buffer.from('dummy tar content'));
}
return Promise.resolve();
}),
x: vi.fn().mockResolvedValue(undefined),
t: vi.fn().mockResolvedValue(undefined),
r: vi.fn().mockResolvedValue(undefined),
u: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('zlib');
vi.mock('uuid');
vi.mock('../utils/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../config/config.js', () => ({
setTargetDir: vi.fn(),
}));
vi.mock('node:stream/promises', () => ({
pipeline: vi.fn(),
}));
vi.mock('../types.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../types.js')>();
return {
...actual,
getPersistedState: vi.fn(),
};
});
const mockStorage = Storage as MockedClass<typeof Storage>;
const mockFse = fse as Mocked<typeof fse>;
const mockCreateReadStream = fsMocks.createReadStream;
const mockTar = tar as Mocked<typeof tar>;
const mockGzipSync = gzipSync as Mock;
const mockGunzipSync = gunzipSync as Mock;
const mockUuidv4 = uuidv4 as Mock;
const mockSetTargetDir = configModule.setTargetDir as Mock;
const mockGetPersistedState = getPersistedState as Mock;
const TEST_METADATA_KEY = METADATA_KEY || '__persistedState';
type MockWriteStream = {
emit: Mock<(event: string, ...args: unknown[]) => boolean>;
removeListener: Mock<
(event: string, cb: (error?: Error | null) => void) => MockWriteStream
>;
once: Mock<
(event: string, cb: (error?: Error | null) => void) => MockWriteStream
>;
on: Mock<
(event: string, cb: (error?: Error | null) => void) => MockWriteStream
>;
destroy: Mock<() => void>;
write: Mock<(chunk: unknown, encoding?: unknown, cb?: unknown) => boolean>;
end: Mock<(cb?: unknown) => void>;
destroyed: boolean;
};
type MockFile = {
save: Mock<(data: Buffer | string) => Promise<void>>;
download: Mock<() => Promise<[Buffer]>>;
exists: Mock<() => Promise<[boolean]>>;
createWriteStream: Mock<() => MockWriteStream>;
};
type MockBucket = {
exists: Mock<() => Promise<[boolean]>>;
file: Mock<(path: string) => MockFile>;
name: string;
};
type MockStorageInstance = {
bucket: Mock<(name: string) => MockBucket>;
getBuckets: Mock<() => Promise<[Array<{ name: string }>]>>;
createBucket: Mock<(name: string) => Promise<[MockBucket]>>;
};
describe('GCSTaskStore', () => {
let bucketName: string;
let mockBucket: MockBucket;
let mockFile: MockFile;
let mockWriteStream: MockWriteStream;
let mockStorageInstance: MockStorageInstance;
beforeEach(() => {
vi.clearAllMocks();
bucketName = 'test-bucket';
mockWriteStream = {
emit: vi.fn().mockReturnValue(true),
removeListener: vi.fn().mockReturnValue(mockWriteStream),
on: vi.fn((event, cb) => {
if (event === 'finish') setTimeout(cb, 0); // Simulate async finish
return mockWriteStream;
}),
once: vi.fn((event, cb) => {
if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream;
}),
destroy: vi.fn(),
write: vi.fn().mockReturnValue(true),
end: vi.fn(),
destroyed: false,
};
mockFile = {
save: vi.fn().mockResolvedValue(undefined),
download: vi.fn().mockResolvedValue([Buffer.from('')]),
exists: vi.fn().mockResolvedValue([true]),
createWriteStream: vi.fn().mockReturnValue(mockWriteStream),
};
mockBucket = {
exists: vi.fn().mockResolvedValue([true]),
file: vi.fn().mockReturnValue(mockFile),
name: bucketName,
};
mockStorageInstance = {
bucket: vi.fn().mockReturnValue(mockBucket),
getBuckets: vi.fn().mockResolvedValue([[{ name: bucketName }]]),
createBucket: vi.fn().mockResolvedValue([mockBucket]),
};
mockStorage.mockReturnValue(mockStorageInstance as unknown as Storage);
mockUuidv4.mockReturnValue('test-uuid');
mockSetTargetDir.mockReturnValue('/tmp/workdir');
mockGetPersistedState.mockReturnValue({
_agentSettings: {},
_taskState: 'submitted',
});
(fse.pathExists as Mock).mockResolvedValue(true);
fsMocks.readdir.mockResolvedValue(['file1.txt']);
mockFse.remove.mockResolvedValue(undefined);
mockFse.ensureDir.mockResolvedValue(undefined);
mockGzipSync.mockReturnValue(Buffer.from('compressed'));
mockGunzipSync.mockReturnValue(Buffer.from('{}'));
mockCreateReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn() });
mockFse.createReadStream.mockReturnValue({
on: vi.fn(),
pipe: vi.fn(),
} as unknown as import('node:fs').ReadStream);
});
describe('Constructor & Initialization', () => {
it('should initialize and check bucket existence', async () => {
const store = new GCSTaskStore(bucketName);
await store['ensureBucketInitialized']();
expect(mockStorage).toHaveBeenCalledTimes(1);
expect(mockStorageInstance.getBuckets).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Bucket test-bucket exists'),
);
});
it('should create bucket if it does not exist', async () => {
mockStorageInstance.getBuckets.mockResolvedValue([[]]);
const store = new GCSTaskStore(bucketName);
await store['ensureBucketInitialized']();
expect(mockStorageInstance.createBucket).toHaveBeenCalledWith(bucketName);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Bucket test-bucket created successfully'),
);
});
it('should throw if bucket creation fails', async () => {
mockStorageInstance.getBuckets.mockResolvedValue([[]]);
mockStorageInstance.createBucket.mockRejectedValue(
new Error('Create failed'),
);
const store = new GCSTaskStore(bucketName);
await expect(store['ensureBucketInitialized']()).rejects.toThrow(
'Failed to create GCS bucket test-bucket: Error: Create failed',
);
});
});
describe('save', () => {
const mockTask: SDKTask = {
id: 'task1',
contextId: 'ctx1',
kind: 'task',
status: { state: 'working' },
metadata: {},
};
it('should save metadata and workspace', async () => {
const store = new GCSTaskStore(bucketName);
await store.save(mockTask);
expect(mockFile.save).toHaveBeenCalledTimes(1);
expect(mockTar.c).toHaveBeenCalledTimes(1);
expect(mockFse.remove).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('metadata saved to GCS'),
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('workspace saved to GCS'),
);
});
it('should handle tar creation failure', async () => {
mockFse.pathExists.mockImplementation(
async (path) =>
!path.toString().includes('task-task1-workspace-test-uuid.tar.gz'),
);
const store = new GCSTaskStore(bucketName);
await expect(store.save(mockTask)).rejects.toThrow(
'tar.c command failed to create',
);
});
it('should throw an error if taskId contains path traversal sequences', async () => {
const store = new GCSTaskStore('test-bucket');
const maliciousTask: SDKTask = {
id: '../../../malicious-task',
metadata: {
_internal: {
agentSettings: {
cacheDir: '/tmp/cache',
dataDir: '/tmp/data',
logDir: '/tmp/logs',
tempDir: '/tmp/temp',
},
taskState: 'working',
},
},
kind: 'task',
status: {
state: 'working',
timestamp: new Date().toISOString(),
},
contextId: 'test-context',
history: [],
artifacts: [],
};
await expect(store.save(maliciousTask)).rejects.toThrow(
'Invalid taskId: ../../../malicious-task',
);
});
});
describe('load', () => {
it('should load task metadata and workspace', async () => {
mockGunzipSync.mockReturnValue(
Buffer.from(
JSON.stringify({
[TEST_METADATA_KEY]: {
_agentSettings: {},
_taskState: 'submitted',
},
_contextId: 'ctx1',
}),
),
);
mockFile.download.mockResolvedValue([Buffer.from('compressed metadata')]);
mockFile.download.mockResolvedValueOnce([
Buffer.from('compressed metadata'),
]);
mockBucket.file = vi.fn((path) => {
const newMockFile = { ...mockFile };
if (path.includes('metadata')) {
newMockFile.download = vi
.fn()
.mockResolvedValue([Buffer.from('compressed metadata')]);
newMockFile.exists = vi.fn().mockResolvedValue([true]);
} else {
newMockFile.download = vi
.fn()
.mockResolvedValue([Buffer.from('compressed workspace')]);
newMockFile.exists = vi.fn().mockResolvedValue([true]);
}
return newMockFile;
});
const store = new GCSTaskStore(bucketName);
const task = await store.load('task1');
expect(task).toBeDefined();
expect(task?.id).toBe('task1');
expect(mockBucket.file).toHaveBeenCalledWith(
'tasks/task1/metadata.tar.gz',
);
expect(mockBucket.file).toHaveBeenCalledWith(
'tasks/task1/workspace.tar.gz',
);
expect(mockTar.x).toHaveBeenCalledTimes(1);
expect(mockFse.remove).toHaveBeenCalledTimes(1);
});
it('should return undefined if metadata not found', async () => {
mockFile.exists.mockResolvedValue([false]);
const store = new GCSTaskStore(bucketName);
const task = await store.load('task1');
expect(task).toBeUndefined();
expect(mockBucket.file).toHaveBeenCalledWith(
'tasks/task1/metadata.tar.gz',
);
});
it('should load metadata even if workspace not found', async () => {
mockGunzipSync.mockReturnValue(
Buffer.from(
JSON.stringify({
[TEST_METADATA_KEY]: {
_agentSettings: {},
_taskState: 'submitted',
},
_contextId: 'ctx1',
}),
),
);
mockBucket.file = vi.fn((path) => {
const newMockFile = { ...mockFile };
if (path.includes('workspace.tar.gz')) {
newMockFile.exists = vi.fn().mockResolvedValue([false]);
} else {
newMockFile.exists = vi.fn().mockResolvedValue([true]);
newMockFile.download = vi
.fn()
.mockResolvedValue([Buffer.from('compressed metadata')]);
}
return newMockFile;
});
const store = new GCSTaskStore(bucketName);
const task = await store.load('task1');
expect(task).toBeDefined();
expect(mockTar.x).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('workspace archive not found'),
);
});
});
it('should throw an error if taskId contains path traversal sequences', async () => {
const store = new GCSTaskStore('test-bucket');
const maliciousTaskId = '../../../malicious-task';
await expect(store.load(maliciousTaskId)).rejects.toThrow(
`Invalid taskId: ${maliciousTaskId}`,
);
});
});
describe('NoOpTaskStore', () => {
let realStore: TaskStore;
let noOpStore: NoOpTaskStore;
beforeEach(() => {
// Create a mock of the real store to delegate to
realStore = {
save: vi.fn(),
load: vi.fn().mockResolvedValue({ id: 'task-123' } as SDKTask),
};
noOpStore = new NoOpTaskStore(realStore);
});
it("should not call the real store's save method", async () => {
const mockTask: SDKTask = { id: 'test-task' } as SDKTask;
await noOpStore.save(mockTask);
expect(realStore.save).not.toHaveBeenCalled();
});
it('should delegate the load method to the real store', async () => {
const taskId = 'task-123';
const result = await noOpStore.load(taskId);
expect(realStore.load).toHaveBeenCalledWith(taskId);
expect(result).toBeDefined();
expect(result?.id).toBe(taskId);
});
});
+319
View File
@@ -0,0 +1,319 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Storage } from '@google-cloud/storage';
import { gzipSync, gunzipSync } from 'node:zlib';
import * as tar from 'tar';
import * as fse from 'fs-extra';
import { promises as fsPromises, createReadStream } from 'node:fs';
import { tmpdir } from '@google/gemini-cli-core';
import { join } from 'node:path';
import type { Task as SDKTask } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import { logger } from '../utils/logger.js';
import { setTargetDir } from '../config/config.js';
import { getPersistedState, type PersistedTaskMetadata } from '../types.js';
import { v4 as uuidv4 } from 'uuid';
type ObjectType = 'metadata' | 'workspace';
const getTmpArchiveFilename = (taskId: string): string =>
`task-${taskId}-workspace-${uuidv4()}.tar.gz`;
// Validate the taskId to prevent path traversal attacks by ensuring it only contains safe characters.
const isTaskIdValid = (taskId: string): boolean => {
// Allow only alphanumeric characters, dashes, and underscores, and ensure it's not empty.
const validTaskIdRegex = /^[a-zA-Z0-9_-]+$/;
return validTaskIdRegex.test(taskId);
};
export class GCSTaskStore implements TaskStore {
private storage: Storage;
private bucketName: string;
private bucketInitialized: Promise<void>;
constructor(bucketName: string) {
if (!bucketName) {
throw new Error('GCS bucket name is required.');
}
this.storage = new Storage();
this.bucketName = bucketName;
logger.info(`GCSTaskStore initializing with bucket: ${this.bucketName}`);
// Prerequisites: user account or service account must have storage admin IAM role
// and the bucket name must be unique.
this.bucketInitialized = this.initializeBucket();
}
private async initializeBucket(): Promise<void> {
try {
const [buckets] = await this.storage.getBuckets();
const exists = buckets.some((bucket) => bucket.name === this.bucketName);
if (!exists) {
logger.info(
`Bucket ${this.bucketName} does not exist in the list. Attempting to create...`,
);
try {
await this.storage.createBucket(this.bucketName);
logger.info(`Bucket ${this.bucketName} created successfully.`);
} catch (createError) {
logger.info(
`Failed to create bucket ${this.bucketName}: ${createError}`,
);
throw new Error(
`Failed to create GCS bucket ${this.bucketName}: ${createError}`,
);
}
} else {
logger.info(`Bucket ${this.bucketName} exists.`);
}
} catch (error) {
logger.info(
`Error during bucket initialization for ${this.bucketName}: ${error}`,
);
throw new Error(
`Failed to initialize GCS bucket ${this.bucketName}: ${error}`,
);
}
}
private async ensureBucketInitialized(): Promise<void> {
await this.bucketInitialized;
}
private getObjectPath(taskId: string, type: ObjectType): string {
if (!isTaskIdValid(taskId)) {
throw new Error(`Invalid taskId: ${taskId}`);
}
return `tasks/${taskId}/${type}.tar.gz`;
}
async save(task: SDKTask): Promise<void> {
await this.ensureBucketInitialized();
const taskId = task.id;
const persistedState = getPersistedState(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
task.metadata as PersistedTaskMetadata,
);
if (!persistedState) {
throw new Error(`Task ${taskId} is missing persisted state in metadata.`);
}
const workDir = process.cwd();
const metadataObjectPath = this.getObjectPath(taskId, 'metadata');
const workspaceObjectPath = this.getObjectPath(taskId, 'workspace');
const dataToStore = task.metadata;
try {
const jsonString = JSON.stringify(dataToStore);
const compressedMetadata = gzipSync(Buffer.from(jsonString));
const metadataFile = this.storage
.bucket(this.bucketName)
.file(metadataObjectPath);
await metadataFile.save(compressedMetadata, {
contentType: 'application/gzip',
});
logger.info(
`Task ${taskId} metadata saved to GCS: gs://${this.bucketName}/${metadataObjectPath}`,
);
if (await fse.pathExists(workDir)) {
const entries = await fsPromises.readdir(workDir);
if (entries.length > 0) {
const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId));
try {
await tar.c(
{
gzip: true,
file: tmpArchiveFile,
cwd: workDir,
portable: true,
},
entries,
);
if (!(await fse.pathExists(tmpArchiveFile))) {
throw new Error(
`tar.c command failed to create ${tmpArchiveFile}`,
);
}
const workspaceFile = this.storage
.bucket(this.bucketName)
.file(workspaceObjectPath);
const sourceStream = createReadStream(tmpArchiveFile);
const destStream = workspaceFile.createWriteStream({
contentType: 'application/gzip',
resumable: true,
});
await new Promise<void>((resolve, reject) => {
sourceStream.on('error', (err) => {
logger.error(
`Error in source stream for ${tmpArchiveFile}:`,
err,
);
// Attempt to close destStream if source fails
if (!destStream.destroyed) {
destStream.destroy(err);
}
reject(err);
});
destStream.on('error', (err) => {
logger.error(
`Error in GCS dest stream for ${workspaceObjectPath}:`,
err,
);
reject(err);
});
destStream.on('finish', () => {
logger.info(
`GCS destStream finished for ${workspaceObjectPath}`,
);
resolve();
});
logger.info(
`Piping ${tmpArchiveFile} to GCS object ${workspaceObjectPath}`,
);
sourceStream.pipe(destStream);
});
logger.info(
`Task ${taskId} workspace saved to GCS: gs://${this.bucketName}/${workspaceObjectPath}`,
);
} catch (error) {
logger.error(
`Error during workspace save process for ${taskId}:`,
error,
);
throw error;
} finally {
logger.info(`Cleaning up temporary file: ${tmpArchiveFile}`);
try {
if (await fse.pathExists(tmpArchiveFile)) {
await fse.remove(tmpArchiveFile);
logger.info(
`Successfully removed temporary file: ${tmpArchiveFile}`,
);
} else {
logger.warn(
`Temporary file not found for cleanup: ${tmpArchiveFile}`,
);
}
} catch (removeError) {
logger.error(
`Error removing temporary file ${tmpArchiveFile}:`,
removeError,
);
}
}
} else {
logger.info(
`Workspace directory ${workDir} is empty, skipping workspace save for task ${taskId}.`,
);
}
} else {
logger.info(
`Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`,
);
}
} catch (error) {
logger.error(`Failed to save task ${taskId} to GCS:`, error);
throw error;
}
}
async load(taskId: string): Promise<SDKTask | undefined> {
await this.ensureBucketInitialized();
const metadataObjectPath = this.getObjectPath(taskId, 'metadata');
const workspaceObjectPath = this.getObjectPath(taskId, 'workspace');
try {
const metadataFile = this.storage
.bucket(this.bucketName)
.file(metadataObjectPath);
const [metadataExists] = await metadataFile.exists();
if (!metadataExists) {
logger.info(`Task ${taskId} metadata not found in GCS.`);
return undefined;
}
const [compressedMetadata] = await metadataFile.download();
const jsonData = gunzipSync(compressedMetadata).toString();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const loadedMetadata = JSON.parse(jsonData);
logger.info(`Task ${taskId} metadata loaded from GCS.`);
const persistedState = getPersistedState(loadedMetadata);
if (!persistedState) {
throw new Error(
`Loaded metadata for task ${taskId} is missing internal persisted state.`,
);
}
const agentSettings = persistedState._agentSettings;
const workDir = setTargetDir(agentSettings);
await fse.ensureDir(workDir);
const workspaceFile = this.storage
.bucket(this.bucketName)
.file(workspaceObjectPath);
const [workspaceExists] = await workspaceFile.exists();
if (workspaceExists) {
const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId));
try {
await workspaceFile.download({ destination: tmpArchiveFile });
await tar.x({ file: tmpArchiveFile, cwd: workDir });
logger.info(
`Task ${taskId} workspace restored from GCS to ${workDir}`,
);
} finally {
if (await fse.pathExists(tmpArchiveFile)) {
await fse.remove(tmpArchiveFile);
}
}
} else {
logger.info(`Task ${taskId} workspace archive not found in GCS.`);
}
return {
id: taskId,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
contextId: loadedMetadata._contextId || uuidv4(),
kind: 'task',
status: {
state: persistedState._taskState,
timestamp: new Date().toISOString(),
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
metadata: loadedMetadata,
history: [],
artifacts: [],
};
} catch (error) {
logger.error(`Failed to load task ${taskId} from GCS:`, error);
throw error;
}
}
}
export class NoOpTaskStore implements TaskStore {
constructor(private realStore: TaskStore) {}
async save(task: SDKTask): Promise<void> {
logger.info(`[NoOpTaskStore] save called for task ${task.id} - IGNORED`);
return Promise.resolve();
}
async load(taskId: string): Promise<SDKTask | undefined> {
logger.info(
`[NoOpTaskStore] load called for task ${taskId}, delegating to real store.`,
);
return this.realStore.load(taskId);
}
}
+190
View File
@@ -0,0 +1,190 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
MCPServerStatus,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import type { TaskState } from '@a2a-js/sdk';
// Interfaces and enums for the CoderAgent protocol.
export enum CoderAgentEvent {
/**
* An event requesting one or more tool call confirmations.
*/
ToolCallConfirmationEvent = 'tool-call-confirmation',
/**
* An event updating on the status of one or more tool calls.
*/
ToolCallUpdateEvent = 'tool-call-update',
/**
* An event providing text updates on the task.
*/
TextContentEvent = 'text-content',
/**
* An event that indicates a change in the task's execution state.
*/
StateChangeEvent = 'state-change',
/**
* An user-sent event to initiate the agent.
*/
StateAgentSettingsEvent = 'agent-settings',
/**
* An event that contains a thought from the agent.
*/
ThoughtEvent = 'thought',
/**
* An event that contains citation from the agent.
*/
CitationEvent = 'citation',
}
export interface AgentSettings {
kind: CoderAgentEvent.StateAgentSettingsEvent;
workspacePath: string;
autoExecute?: boolean;
isTrusted?: boolean;
}
export interface ToolCallConfirmation {
kind: CoderAgentEvent.ToolCallConfirmationEvent;
}
export interface ToolCallUpdate {
kind: CoderAgentEvent.ToolCallUpdateEvent;
}
export interface TextContent {
kind: CoderAgentEvent.TextContentEvent;
}
export interface StateChange {
kind: CoderAgentEvent.StateChangeEvent;
}
export interface Thought {
kind: CoderAgentEvent.ThoughtEvent;
}
export interface Citation {
kind: CoderAgentEvent.CitationEvent;
}
export type ThoughtSummary = {
subject: string;
description: string;
};
export interface ToolConfirmationResponse {
outcome: ToolConfirmationOutcome;
callId: string;
}
export type CoderAgentMessage =
| AgentSettings
| ToolCallConfirmation
| ToolCallUpdate
| TextContent
| StateChange
| Thought
| Citation;
export interface TaskMetadata {
id: string;
contextId: string;
taskState: TaskState;
model: string;
mcpServers: Array<{
name: string;
status: MCPServerStatus;
tools: Array<{
name: string;
description: string;
parameterSchema: unknown;
}>;
}>;
availableTools: Array<{
name: string;
description: string;
parameterSchema: unknown;
}>;
}
export interface PersistedStateMetadata {
_agentSettings: AgentSettings;
_taskState: TaskState;
}
export type PersistedTaskMetadata = { [k: string]: unknown };
export const METADATA_KEY = '__persistedState';
function isAgentSettings(value: unknown): value is AgentSettings {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
value.kind === CoderAgentEvent.StateAgentSettingsEvent &&
'workspacePath' in value &&
typeof value.workspacePath === 'string'
);
}
function isPersistedStateMetadata(
value: unknown,
): value is PersistedStateMetadata {
return (
typeof value === 'object' &&
value !== null &&
'_agentSettings' in value &&
'_taskState' in value &&
isAgentSettings(value._agentSettings)
);
}
export function getPersistedState(
metadata: PersistedTaskMetadata,
): PersistedStateMetadata | undefined {
const state = metadata?.[METADATA_KEY];
if (isPersistedStateMetadata(state)) {
return state;
}
return undefined;
}
export function getContextIdFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): string | undefined {
if (!metadata) {
return undefined;
}
const contextId = metadata['_contextId'];
return typeof contextId === 'string' ? contextId : undefined;
}
export function getAgentSettingsFromMetadata(
metadata: PersistedTaskMetadata | undefined,
): AgentSettings | undefined {
if (!metadata) {
return undefined;
}
const coderAgent = metadata['coderAgent'];
if (isAgentSettings(coderAgent)) {
return coderAgent;
}
return undefined;
}
export function setPersistedState(
metadata: PersistedTaskMetadata,
state: PersistedStateMetadata,
): PersistedTaskMetadata {
return {
...metadata,
[METADATA_KEY]: state,
};
}
@@ -0,0 +1,51 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Message } from '@a2a-js/sdk';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { v4 as uuidv4 } from 'uuid';
import { CoderAgentEvent, type StateChange } from '../types.js';
export async function pushTaskStateFailed(
error: unknown,
eventBus: ExecutionEventBus,
taskId: string,
contextId: string,
) {
const errorMessage =
error instanceof Error ? error.message : 'Agent execution error';
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
};
eventBus.publish({
kind: 'status-update',
taskId,
contextId,
status: {
state: 'failed',
message: {
kind: 'message',
role: 'agent',
parts: [
{
kind: 'text',
text: errorMessage,
},
],
messageId: uuidv4(),
taskId,
contextId,
} as Message,
},
final: true,
metadata: {
coderAgent: stateChange,
model: 'unknown',
error: errorMessage,
},
});
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
// First, add a timestamp to the log info object
winston.format.timestamp({
format: 'YYYY-MM-DD HH:mm:ss.SSS A', // Custom timestamp format
}),
// Here we define the custom output format
winston.format.printf((info) => {
const { level, timestamp, message, ...rest } = info;
return (
`[${level.toUpperCase()}] ${timestamp} -- ${message}` +
`${Object.keys(rest).length > 0 ? `\n${JSON.stringify(rest, null, 2)}` : ''}`
); // Only print ...rest if present
}),
),
transports: [new winston.transports.Console()],
});
export { logger };
@@ -0,0 +1,233 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type {
Task as SDKTask,
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
} from '@a2a-js/sdk';
import {
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
type MessageBus,
PolicyDecision,
tmpdir,
type Config,
type Storage,
NoopSandboxManager,
type ToolRegistry,
type SandboxManager,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import { expect, vi } from 'vitest';
export function createMockConfig(
overrides: Partial<Config> = {},
): Partial<Config> {
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
get config() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return this as unknown as Config;
},
get toolRegistry() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = this as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return config.getToolRegistry?.() as unknown as ToolRegistry;
},
get messageBus() {
return (
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(this as unknown as Config).getMessageBus?.() as unknown as MessageBus
);
},
get geminiClient() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const config = this as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return config.getGeminiClient?.() as unknown as GeminiClient;
},
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn(),
getAllToolNames: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
getToolsByServer: vi.fn().mockReturnValue([]),
}),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getIdeMode: vi.fn().mockReturnValue(false),
isInteractive: () => true,
getAllowedTools: vi.fn().mockReturnValue([]),
getWorkspaceContext: vi.fn().mockReturnValue({
isPathWithinWorkspace: () => true,
}),
getTargetDir: () => tmpDir,
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
storage: {
getProjectTempDir: () => tmpDir,
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
} as Storage,
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
getDebugMode: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
setFallbackModelHandler: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
getProxy: vi.fn().mockReturnValue(undefined),
getHistory: vi.fn().mockReturnValue([]),
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getUserTier: vi.fn(),
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getEnableHooks: vi.fn().mockReturnValue(false),
getMcpClientManager: vi.fn().mockReturnValue({
getMcpServers: vi.fn().mockReturnValue({}),
}),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
sandboxManager: new NoopSandboxManager() as unknown as SandboxManager,
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
}),
isContextManagementEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
getExperimentalGemma: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
'test-prompt-id';
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
.mockReturnValue(new HookSystem(mockConfig));
mockConfig.getGeminiClient = vi
.fn()
.mockReturnValue(new GeminiClient(mockConfig));
mockConfig.getPolicyEngine = vi.fn().mockReturnValue({
check: async () => {
const mode = mockConfig.getApprovalMode();
if (mode === ApprovalMode.YOLO) {
return { decision: PolicyDecision.ALLOW };
}
return { decision: PolicyDecision.ASK_USER };
},
});
return mockConfig;
}
export function createStreamMessageRequest(
text: string,
messageId: string,
taskId?: string,
) {
const request: {
jsonrpc: string;
id: string;
method: string;
params: {
message: {
kind: string;
role: string;
parts: [{ kind: string; text: string }];
messageId: string;
};
metadata: {
coderAgent: {
kind: string;
workspacePath: string;
};
};
taskId?: string;
};
} = {
jsonrpc: '2.0',
id: '1',
method: 'message/stream',
params: {
message: {
kind: 'message',
role: 'user',
parts: [{ kind: 'text', text }],
messageId,
},
metadata: {
coderAgent: {
kind: 'agent-settings',
workspacePath: '/tmp',
},
},
},
};
if (taskId) {
request.params.taskId = taskId;
}
return request;
}
export function assertUniqueFinalEventIsLast(
events: SendStreamingMessageSuccessResponse[],
) {
// Final event is input-required & final
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent;
expect(finalEvent.metadata?.['coderAgent']).toMatchObject({
kind: 'state-change',
});
expect(finalEvent.status?.state).toBe('input-required');
expect(finalEvent.final).toBe(true);
// There is only one event with final and its the last
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length,
).toBe(1);
expect(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final),
).toBe(events.length - 1);
}
export function assertTaskCreationAndWorkingStatus(
events: SendStreamingMessageSuccessResponse[],
) {
// Initial task creation event
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const taskEvent = events[0].result as SDKTask;
expect(taskEvent.kind).toBe('task');
expect(taskEvent.status.state).toBe('submitted');
// Status update: working
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const workingEvent = events[1].result as TaskStatusUpdateEvent;
expect(workingEvent.kind).toBe('status-update');
expect(workingEvent.status.state).toBe('working');
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"composite": true,
"types": ["node", "vitest/globals"]
},
"include": ["index.ts", "src/**/*.ts", "src/**/*.json"],
"exclude": ["node_modules", "dist"]
}
+46
View File
@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
exclude: ['**/node_modules/**', '**/dist/**'],
globals: true,
reporters: ['default', 'junit'],
silent: true,
outputFile: {
junit: 'junit.xml',
},
coverage: {
enabled: true,
provider: 'v8',
reportsDirectory: './coverage',
include: ['src/**/*'],
reporter: [
['text', { file: 'full-text-summary.txt' }],
'html',
'json',
'lcov',
'cobertura',
['json-summary', { outputFile: 'coverage-summary.json' }],
],
},
poolOptions: {
threads: {
minThreads: 8,
maxThreads: 16,
},
},
server: {
deps: {
inline: [/@google\/gemini-cli-core/],
},
},
},
});
+28
View File
@@ -0,0 +1,28 @@
## React & Ink (CLI UI)
- **Side Effects**: Use reducers for complex state transitions; avoid `setState`
triggers in callbacks.
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
dependencies.
- **Shortcuts**: only define keyboard shortcuts in
`packages/cli/src/ui/key/keyBindings.ts`
- Do not implement any logic performing custom string measurement or string
truncation. Use Ink layout instead leveraging ResizeObserver as needed. When
using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
element is available, avoiding potential rendering timing issues.
- Avoid prop drilling when at all possible.
## Testing
- **Utilities**: Use `renderWithProviders` and `waitFor` from
`packages/cli/src/test-utils/`.
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for
UI components whenever colors or detailed visual layout matter. SVG snapshots
capture styling accurately. Make sure to await the `waitUntilReady()` of the
render result before asserting. After updating SVG snapshots, always examine
the resulting `.svg` files (e.g. by reading their content or visually
inspecting them) to ensure the render and colors actually look as expected and
don't just contain an error message.
- **Mocks**: Use mocks as sparingly as possible.
@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { render, Box, Text } from 'ink';
import { AskUserDialog } from '../src/ui/components/AskUserDialog.js';
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
const DEMO_QUESTIONS: Question[] = [
{
question: 'What type of project are you building?',
header: 'Project Type',
options: [
{ label: 'Web Application', description: 'React, Next.js, or similar' },
{ label: 'CLI Tool', description: 'Command-line interface with Node.js' },
{ label: 'Library', description: 'NPM package or shared utility' },
],
multiSelect: false,
},
{
question: 'Which features should be enabled?',
header: 'Features',
options: [
{ label: 'TypeScript', description: 'Add static typing' },
{ label: 'ESLint', description: 'Add linting and formatting' },
{ label: 'Unit Tests', description: 'Add Vitest setup' },
{ label: 'CI/CD', description: 'Add GitHub Actions' },
],
multiSelect: true,
},
{
question: 'What is the project name?',
header: 'Name',
type: QuestionType.TEXT,
placeholder: 'my-awesome-project',
},
{
question: 'Initialize git repository?',
header: 'Git',
type: QuestionType.YESNO,
},
];
const Demo = () => {
const [result, setResult] = useState<null | { [key: string]: string }>(null);
const [cancelled, setCancelled] = useState(false);
if (cancelled) {
return (
<Box padding={1}>
<Text color="red">
Dialog was cancelled. Project initialization aborted.
</Text>
</Box>
);
}
if (result) {
return (
<Box
flexDirection="column"
padding={1}
borderStyle="single"
borderColor="green"
>
<Text bold color="green">
Success! Project Configuration:
</Text>
{DEMO_QUESTIONS.map((q, i) => (
<Box key={i} marginTop={1}>
<Text color="gray">{q.header}: </Text>
<Text>{result[i] || '(not answered)'}</Text>
</Box>
))}
<Box marginTop={1}>
<Text color="dim">Press Ctrl+C to exit</Text>
</Box>
</Box>
);
}
return (
<KeypressProvider>
<Box padding={1} flexDirection="column">
<Text bold marginBottom={1}>
AskUserDialog Demo
</Text>
<AskUserDialog
questions={DEMO_QUESTIONS}
onSubmit={setResult}
onCancel={() => setCancelled(true)}
/>
</Box>
</KeypressProvider>
);
};
render(<Demo />);
@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import { render, Box, Text, useInput, useStdout } from 'ink';
import {
ScrollableList,
type ScrollableListRef,
} from '../src/ui/components/shared/ScrollableList.js';
import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js';
import { MouseProvider } from '../src/ui/contexts/MouseContext.js';
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
import {
enableMouseEvents,
disableMouseEvents,
} from '../src/ui/utils/mouse.js';
interface Item {
id: string;
title: string;
}
const getLorem = (index: number) =>
Array(10)
.fill(null)
.map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim())
.join('\n');
const Demo = () => {
const { stdout } = useStdout();
const [size, setSize] = useState({
columns: stdout.columns,
rows: stdout.rows,
});
useEffect(() => {
const onResize = () => {
setSize({
columns: stdout.columns,
rows: stdout.rows,
});
};
stdout.on('resize', onResize);
return () => {
stdout.off('resize', onResize);
};
}, [stdout]);
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: 1000 }, (_, i) => ({
id: String(i),
title: `Item ${i + 1}`,
})),
);
const listRef = useRef<ScrollableListRef<Item>>(null);
useInput((input, key) => {
if (input === 'a' || input === 'A') {
setItems((prev) => [
...prev,
{ id: String(prev.length), title: `Item ${prev.length + 1}` },
]);
}
if ((input === 'e' || input === 'E') && !key.ctrl) {
setItems((prev) => {
if (prev.length === 0) return prev;
const lastIndex = prev.length - 1;
const lastItem = prev[lastIndex]!;
const newItem = { ...lastItem, title: lastItem.title + 'e' };
return [...prev.slice(0, lastIndex), newItem];
});
}
if (key.ctrl && input === 'e') {
listRef.current?.scrollToEnd();
}
// Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed.
// For alternate buffer, explicit handling is often safer for cleanup.
if (key.escape || (key.ctrl && input === 'c')) {
process.exit(0);
}
});
return (
<MouseProvider mouseEventsEnabled={true}>
<KeypressProvider>
<ScrollProvider>
<Box
flexDirection="column"
width={size.columns}
height={size.rows - 1}
padding={1}
>
<Text>
Press &apos;A&apos; to add an item. Press &apos;E&apos; to edit
last item. Press &apos;Ctrl+E&apos; to scroll to end. Press
&apos;Esc&apos; to exit. Mouse wheel or Shift+Up/Down to scroll.
</Text>
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
<ScrollableList
ref={listRef}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" paddingBottom={2}>
<Box
sticky
flexDirection="column"
width={size.columns - 2}
opaque
stickyChildren={
<Box
flexDirection="column"
width={size.columns - 2}
opaque
>
<Text>{item.title}</Text>
<Box
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor="gray"
/>
</Box>
}
>
<Text>{item.title}</Text>
</Box>
<Text color="gray">{getLorem(index)}</Text>
</Box>
)}
estimatedItemHeight={() => 14}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
<Text>Count: {items.length}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
// Enable mouse reporting before rendering
enableMouseEvents();
// Ensure cleanup happens on exit
process.on('exit', () => {
disableMouseEvents();
});
// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time
process.on('SIGINT', () => {
process.exit(0);
});
render(<Demo />, { alternateBuffer: true });
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import os from 'node:os';
import v8 from 'node:v8';
import {
RELAUNCH_EXIT_CODE,
getSpawnConfig,
getScriptArgs,
} from './src/utils/processUtils.js';
// --- Global Entry Point ---
// Suppress known race condition error in node-pty on Windows and Linux
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
if (error instanceof Error) {
const message = error.message || '';
const isPtyResizeError =
message === 'Cannot resize a pty that has already exited';
const isEbadfError =
message.includes('EBADF') ||
(error as { code?: string }).code === 'EBADF';
const isFromNodePty =
error.stack?.includes('node-pty') || error.stack?.includes('PtyResize');
if ((isPtyResizeError || isEbadfError) && isFromNodePty) {
// This error happens with node-pty when resizing a pty that has just exited.
// It is a race condition in node-pty that we cannot prevent, so we silence it.
return;
}
}
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
process.stderr.write(error.stack + '\n');
} else {
process.stderr.write(String(error) + '\n');
}
process.exit(1);
});
async function getMemoryNodeArgs(): Promise<string[]> {
let autoConfigureMemory = true;
try {
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
// Respect GEMINI_CLI_HOME environment variable, falling back to os.homedir()
const baseDir =
process.env['GEMINI_CLI_HOME'] || join(os.homedir(), '.gemini');
const settingsPath = join(baseDir, 'settings.json');
const rawSettings = readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(rawSettings);
if (settings?.advanced?.autoConfigureMemory === false) {
autoConfigureMemory = false;
}
} catch {
// ignore
}
if (autoConfigureMemory) {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
const currentMaxOldSpaceSizeMb = Math.floor(
heapStats.heap_size_limit / 1024 / 1024,
);
const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5);
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
}
}
return [];
}
async function run() {
if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
// --- Lightweight Parent Process / Daemon ---
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
const scriptArgs = getScriptArgs();
const memoryArgs = await getMemoryNodeArgs();
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
let latestAdminSettings: unknown = undefined;
// Prevent the parent process from exiting prematurely on signals.
// The child process will receive the same signals and handle its own cleanup.
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
process.on(sig as NodeJS.Signals, () => {});
}
const runner = () => {
process.stdin.pause();
const child = spawn(process.execPath, spawnArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
if (latestAdminSettings) {
child.send({ type: 'admin-settings', settings: latestAdminSettings });
}
child.on('message', (msg: { type?: string; settings?: unknown }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings;
}
});
return new Promise<number>((resolve) => {
child.on('error', (err) => {
process.stderr.write(
'Error: Failed to start child process: ' + err.message + '\n',
);
resolve(1);
});
child.on('close', (code) => {
process.stdin.resume();
resolve(code ?? 1);
});
});
};
while (true) {
try {
const exitCode = await runner();
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
process.exit(exitCode);
}
} catch (error: unknown) {
process.stdin.resume();
process.stderr.write(
`Fatal error: Failed to relaunch the CLI process.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
);
process.exit(1);
}
}
} else {
// --- Heavy Child Process ---
// Now we can safely import everything.
const { main } = await import('./src/gemini.js');
const { FatalError, writeToStderr } = await import(
'@google/gemini-cli-core'
);
const { runExitCleanup } = await import('./src/utils/cleanup.js');
main().catch(async (error: unknown) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
writeToStderr('Cleanup timed out, forcing exit...\n');
process.exit(1);
}, 5000);
try {
await runExitCleanup();
} catch (cleanupError: unknown) {
writeToStderr(
`Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`,
);
} finally {
clearTimeout(cleanupTimeout);
}
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
}
}
run();
+91
View File
@@ -0,0 +1,91 @@
{
"name": "@google/gemini-cli",
"version": "0.52.0-nightly.20260707.g27a3da3e8",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"type": "module",
"main": "dist/index.js",
"bin": {
"gemini": "dist/index.js"
},
"scripts": {
"build": "node ../../scripts/build_package.js",
"start": "node dist/index.js",
"debug": "node --inspect-brk dist/index.js",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"posttest": "npm run build",
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.52.0-nightly.20260707.g27a3da3e8"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@iarna/toml": "2.2.5",
"@modelcontextprotocol/sdk": "1.23.0",
"ansi-escapes": "7.3.0",
"ansi-regex": "6.2.2",
"chalk": "4.1.2",
"cli-spinners": "2.9.2",
"clipboardy": "5.2.0",
"color-convert": "2.0.1",
"command-exists": "1.2.9",
"comment-json": "4.2.5",
"diff": "8.0.3",
"dotenv": "17.1.0",
"extract-zip": "2.0.1",
"fzf": "0.5.2",
"glob": "12.0.0",
"highlight.js": "11.11.1",
"ink": "npm:@jrichman/ink@6.6.9",
"ink-gradient": "3.0.0",
"ink-spinner": "5.0.0",
"latest-version": "9.0.0",
"lowlight": "3.3.0",
"mnemonist": "0.40.3",
"open": "10.1.2",
"prompts": "2.4.2",
"proper-lockfile": "4.1.2",
"react": "19.2.4",
"shell-quote": "1.8.3",
"simple-git": "3.28.0",
"string-width": "8.1.0",
"strip-ansi": "7.1.0",
"strip-json-comments": "3.1.1",
"tar": "7.5.8",
"tinygradient": "1.1.5",
"undici": "7.10.0",
"ws": "8.16.0",
"yargs": "17.7.2",
"zod": "3.25.76"
},
"devDependencies": {
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/command-exists": "1.2.3",
"@types/hast": "3.0.4",
"@types/node": "20.11.24",
"@types/react": "19.2.0",
"@types/semver": "7.7.0",
"@types/shell-quote": "1.7.5",
"@types/ws": "8.5.10",
"@types/yargs": "17.0.32",
"@xterm/headless": "5.5.0",
"typescript": "5.8.3",
"vitest": "3.2.4"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
"Use mock tool
Use mock tool again
Finished.
"
`;
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
"
`;
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
"Use mock tool
Use mock tool again
Finished.
"
`;
+81
View File
@@ -0,0 +1,81 @@
# Agent Client Protocol (ACP) Implementation
This directory contains the implementation of the Agent Client Protocol (ACP)
for the Gemini CLI. The ACP allows external clients (like IDE extensions) to
communicate with the Gemini CLI agent over a structured JSON-RPC based protocol.
## Directory Structure
Following Phase 1 of the modularization refactor, the ACP client is organized
into the following specialized modules, all sharing the `acp` prefix for
consistency:
- **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets
up the Web streams for standard input/output and creates the
`AgentSideConnection` using line-delimited JSON (ndjson).
- **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent`
class. This is the main entry point for incoming JSON-RPC messages. It
implements the protocol methods and delegates session-specific work to the
manager and individual sessions.
- **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session
state. It handles session creation (`newSession`), loading (`loadSession`),
and configuration, isolating session state from the RPC routing.
- **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions.
It handles prompt execution, `@path` file resolution, tool execution, command
interception, and streaming updates back to the client.
- **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type
mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used
across the modules.
- **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to
ACP-compliant error codes.
- **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and
execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts.
- **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to
the file system restricted by the workspace boundaries and permissions.
## Development Instructions
### Running Tests
Tests are co-located with the source files:
- `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and
handler delegation.
- `acpSessionManager.test.ts`: Tests for session lifecycle and configuration.
- `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path
resolution.
- `acpResume.test.ts`: Integration tests for loading/resuming sessions.
To run specific tests, use Vitest with the workspace filter:
```bash
# General pattern
npm test -w @google/gemini-cli -- src/acp/<test-file-name>.ts
# Example
npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts
```
Note: You may need to ensure your environment has Node available. If running in
a restricted environment, try sourcing NVM first:
```bash
source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts
```
### Adding New Features
- **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts`
and register it in the `AgentSideConnection` setup if necessary.
- **Session State**: If a feature requires storing state across turns within a
session, add it to the `Session` class in `acpSession.ts`.
- **Protocol Helpers**: Add any new mapping or serialization logic to
`acpUtils.ts`.
### Coding Conventions
- **Imports**: Use specific imports and do not import across package boundaries
using relative paths.
- **License Headers**: All new files must include the Apache-2.0 license header.
- **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate
untrusted input from the protocol.
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandHandler } from './acpCommandHandler.js';
import { describe, it, expect } from 'vitest';
describe('CommandHandler', () => {
it('parses commands correctly', () => {
const handler = new CommandHandler();
// @ts-expect-error - testing private method
const parse = (query: string) => handler.parseSlashCommand(query);
const memShow = parse('/memory show');
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');
const memList = parse('/memory list');
expect(memList.commandToExecute?.name).toBe('memory list');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
const init = parse('/init');
expect(init.commandToExecute?.name).toBe('init');
const about = parse('/about');
expect(about.commandToExecute?.name).toBe('about');
const help = parse('/help');
expect(help.commandToExecute?.name).toBe('help');
});
});
+138
View File
@@ -0,0 +1,138 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Command, CommandContext } from './commands/types.js';
import { CommandRegistry } from './commands/commandRegistry.js';
import { MemoryCommand } from './commands/memory.js';
import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
import { AboutCommand } from './commands/about.js';
import { HelpCommand } from './commands/help.js';
export class CommandHandler {
private registry: CommandRegistry;
constructor() {
this.registry = CommandHandler.createRegistry();
}
private static createRegistry(): CommandRegistry {
const registry = new CommandRegistry();
registry.register(new MemoryCommand());
registry.register(new ExtensionsCommand());
registry.register(new InitCommand());
registry.register(new RestoreCommand());
registry.register(new AboutCommand());
registry.register(new HelpCommand(registry));
return registry;
}
getAvailableCommands(): Array<{ name: string; description: string }> {
return this.registry.getAllCommands().map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}
/**
* Parses and executes a command string if it matches a registered command.
* Returns true if a command was handled, false otherwise.
*/
async handleCommand(
commandText: string,
context: CommandContext,
): Promise<boolean> {
const { commandToExecute, args } = this.parseSlashCommand(commandText);
if (commandToExecute) {
await this.runCommand(commandToExecute, args, context);
return true;
}
return false;
}
private async runCommand(
commandToExecute: Command,
args: string,
context: CommandContext,
): Promise<void> {
try {
const result = await commandToExecute.execute(
context,
args ? args.split(/\s+/) : [],
);
let messageContent = '';
if (typeof result.data === 'string') {
messageContent = result.data;
} else if (
typeof result.data === 'object' &&
result.data !== null &&
'content' in result.data
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
messageContent = (result.data as Record<string, any>)[
'content'
] as string;
} else {
messageContent = JSON.stringify(result.data, null, 2);
}
await context.sendMessage(messageContent);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
await context.sendMessage(`Error: ${errorMessage}`);
}
}
/**
* Parses a raw slash command string into its matching headless command and arguments.
* Mirrors `packages/cli/src/utils/commands.ts` logic.
*/
private parseSlashCommand(query: string): {
commandToExecute: Command | undefined;
args: string;
} {
const trimmed = query.trim();
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p);
let currentCommands = this.registry.getAllCommands();
let commandToExecute: Command | undefined;
let pathIndex = 0;
for (const part of commandPath) {
const foundCommand = currentCommands.find((cmd) => {
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
return (
cmd.name === part ||
cmd.name === expectedName ||
cmd.aliases?.includes(part) ||
cmd.aliases?.includes(expectedName)
);
});
if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const args = parts.slice(pathIndex).join(' ');
return { commandToExecute, args };
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { getAcpErrorMessage } from './acpErrors.js';
describe('getAcpErrorMessage', () => {
it('should return plain error message', () => {
expect(getAcpErrorMessage(new Error('plain error'))).toBe('plain error');
});
it('should parse simple JSON error response', () => {
const json = JSON.stringify({ error: { message: 'json error' } });
expect(getAcpErrorMessage(new Error(json))).toBe('json error');
});
it('should parse double-encoded JSON error response', () => {
const innerJson = JSON.stringify({ error: { message: 'nested error' } });
const outerJson = JSON.stringify({ error: { message: innerJson } });
expect(getAcpErrorMessage(new Error(outerJson))).toBe('nested error');
});
it('should parse array-style JSON error response', () => {
const json = JSON.stringify([{ error: { message: 'array error' } }]);
expect(getAcpErrorMessage(new Error(json))).toBe('array error');
});
it('should parse JSON with top-level message field', () => {
const json = JSON.stringify({ message: 'top-level message' });
expect(getAcpErrorMessage(new Error(json))).toBe('top-level message');
});
it('should handle JSON with trailing newline', () => {
const json = JSON.stringify({ error: { message: 'newline error' } }) + '\n';
expect(getAcpErrorMessage(new Error(json))).toBe('newline error');
});
it('should return original message if JSON parsing fails', () => {
const invalidJson = '{ not-json }';
expect(getAcpErrorMessage(new Error(invalidJson))).toBe(invalidJson);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getErrorMessage as getCoreErrorMessage } from '@google/gemini-cli-core';
/**
* Extracts a human-readable error message specifically for ACP (IDE) clients.
* This function recursively parses JSON error blobs that are common in
* Google API responses but ugly to display in an IDE's UI.
*/
export function getAcpErrorMessage(error: unknown): string {
const coreMessage = getCoreErrorMessage(error);
return extractRecursiveMessage(coreMessage);
}
function extractRecursiveMessage(input: string): string {
const trimmed = input.trim();
// Attempt to parse JSON error responses (common in Google API errors)
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(trimmed);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const next =
parsed?.error?.message ||
parsed?.[0]?.error?.message ||
parsed?.message;
if (next && typeof next === 'string' && next !== input) {
return extractRecursiveMessage(next);
}
} catch {
// Fall back to original string if parsing fails
}
}
return input;
}
@@ -0,0 +1,236 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import { AcpFileSystemService } from './acpFileSystemService.js';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import type { FileSystemService } from '@google/gemini-cli-core';
import os from 'node:os';
vi.mock('node:os', () => ({
default: {
homedir: vi.fn(),
},
}));
describe('AcpFileSystemService', () => {
let mockConnection: Mocked<AgentSideConnection>;
let mockFallback: Mocked<FileSystemService>;
let service: AcpFileSystemService;
beforeEach(() => {
mockConnection = {
requestPermission: vi.fn(),
sessionUpdate: vi.fn(),
writeTextFile: vi.fn(),
readTextFile: vi.fn(),
} as unknown as Mocked<AgentSideConnection>;
mockFallback = {
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
};
vi.mocked(os.homedir).mockReturnValue('/home/user');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readTextFile', () => {
it.each([
{
capability: true,
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
setup: () => {
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
},
verify: () => {
expect(mockConnection.readTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
sessionId: 'session-1',
});
expect(mockFallback.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/path/to/file',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/outside/file',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])(
'should use $desc',
async ({ capability, path, root, setup, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
mockFallback,
root || '/path/to',
);
setup();
const result = await service.readTextFile(path);
expect(result).toBe('content');
verify();
},
);
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.readTextFile.mockRejectedValue(
new Error('Resource not found for document'),
);
await expect(
service.readTextFile('/path/to/missing'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for document',
});
});
});
describe('writeTextFile', () => {
it.each([
{
capability: true,
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
verify: () => {
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
content: 'content',
sessionId: 'session-1',
});
expect(mockFallback.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/path/to/file',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/outside/file',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, path, root, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ writeTextFile: capability, readTextFile: true },
mockFallback,
root || '/path/to',
);
await service.writeTextFile(path, 'content');
verify();
});
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.writeTextFile.mockRejectedValue(
new Error('Resource not found for directory'),
);
await expect(
service.writeTextFile('/path/to/missing', 'content'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for directory',
});
});
});
});
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import os from 'node:os';
import path from 'node:path';
/**
* ACP client-based implementation of FileSystemService
*/
export class AcpFileSystemService implements FileSystemService {
private readonly geminiDir = path.join(os.homedir(), '.gemini');
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapabilities,
private readonly fallback: FileSystemService,
private readonly root: string,
) {}
private shouldUseFallback(filePath: string): boolean {
// Files inside the global CLI directory must always use the native file system,
// even if the user runs the CLI directly from their home directory (which
// would make the IDE's project root overlap with the global directory).
return (
!isWithinRoot(filePath, this.root) ||
isWithinRoot(filePath, this.geminiDir)
);
}
private normalizeFileSystemError(err: unknown): never {
const errorMessage = err instanceof Error ? err.message : String(err);
if (
errorMessage.includes('Resource not found') ||
errorMessage.includes('ENOENT') ||
errorMessage.includes('does not exist') ||
errorMessage.includes('No such file')
) {
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
newErr.code = 'ENOENT';
throw newErr;
}
throw err;
}
async readTextFile(filePath: string): Promise<string> {
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
return this.fallback.readTextFile(filePath);
}
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
const content: unknown = response.content;
if (typeof content !== 'string') {
throw new Error('content must be a string'); // replace with other response type formats when modified in the future
}
return content;
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
}
async writeTextFile(filePath: string, content: string): Promise<void> {
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
return this.fallback.writeTextFile(filePath, content);
}
try {
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
}
}
+313
View File
@@ -0,0 +1,313 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './acpRpcDispatcher.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
AuthType,
type Config,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import {
SessionSelector,
convertSessionToHistoryFormats,
} from '../utils/sessionUtils.js';
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { waitFor } from '../test-utils/async.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/sessionUtils.js')>();
return {
...actual,
SessionSelector: vi.fn(),
convertSessionToHistoryFormats: vi.fn(),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
CoreToolCallStatus: {
Validating: 'validating',
Scheduled: 'scheduled',
Error: 'error',
Success: 'success',
Executing: 'executing',
Cancelled: 'cancelled',
AwaitingApproval: 'awaiting_approval',
},
LlmRole: {
MAIN: 'main',
SUBAGENT: 'subagent',
UTILITY_TOOL: 'utility_tool',
USER: 'user',
MODEL: 'model',
SYSTEM: 'system',
TOOL: 'tool',
},
convertSessionToClientHistory: vi.fn(),
};
});
describe('GeminiAgent Session Resume', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let agent: GeminiAgent;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getGeminiClient: vi.fn().mockReturnValue({
initialize: vi.fn().mockResolvedValue(undefined),
resumeChat: vi.fn().mockResolvedValue(undefined),
getChat: vi.fn().mockReturnValue({}),
}),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
toolRegistry: {
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
},
get config() {
return this;
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as Mock).mockResolvedValue(mockConfig);
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
});
it('should advertise loadSession capability', async () => {
const response = await agent.initialize({
protocolVersion: acp.PROTOCOL_VERSION,
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should load a session, resume chat, and stream all message types', async () => {
const sessionId = 'existing-session-id';
const sessionData = {
sessionId,
messages: [
{ type: 'user', content: [{ text: 'Hello' }] },
{
type: 'gemini',
content: [{ text: 'Hi there' }],
thoughts: [{ subject: 'Thinking', description: 'about greeting' }],
toolCalls: [
{
id: 'call-1',
name: 'test_tool',
displayName: 'Test Tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'Tool output',
},
],
},
{
type: 'gemini',
content: [{ text: 'Trying a write' }],
toolCalls: [
{
id: 'call-2',
name: 'write_file',
displayName: 'Write File',
status: CoreToolCallStatus.Error,
resultDisplay: 'Permission denied',
},
],
},
],
};
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
sessionData,
sessionPath: '/path/to/session.json',
}),
}));
const mockClientHistory = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there' }] },
];
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
uiHistory: [],
});
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
mockClientHistory,
);
const response = await agent.loadSession({
sessionId,
cwd: '/tmp',
mcpServers: [],
});
expect(response).toEqual({
modes: {
availableModes: [
{
id: ApprovalMode.DEFAULT,
name: 'Default',
description: 'Prompts for approval',
},
{
id: ApprovalMode.AUTO_EDIT,
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{
id: ApprovalMode.YOLO,
name: 'YOLO',
description: 'Auto-approves all tools',
},
{
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
},
],
currentModeId: ApprovalMode.DEFAULT,
},
models: {
availableModels: expect.any(Array) as unknown,
currentModelId: 'gemini-pro',
},
});
// Verify resumeChat received the correct arguments
expect(mockConfig.getGeminiClient().resumeChat).toHaveBeenCalledWith(
mockClientHistory,
expect.objectContaining({
conversation: sessionData,
filePath: '/path/to/session.json',
}),
);
await waitFor(() => {
// User message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'user_message_chunk',
content: expect.objectContaining({ text: 'Hello' }),
}),
}),
);
// Agent thought
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: expect.objectContaining({
text: '**Thinking**\nabout greeting',
}),
}),
}),
);
// Agent message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: expect.objectContaining({ text: 'Hi there' }),
}),
}),
);
// Successful tool call → 'completed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-1',
status: 'completed',
title: 'Test Tool',
kind: 'read',
content: [
{
type: 'content',
content: { type: 'text', text: 'Tool output' },
},
],
}),
}),
);
// Failed tool call → 'failed'
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'tool_call',
toolCallId: 'call-2',
status: 'failed',
title: 'Write File',
kind: 'read',
}),
}),
);
});
});
});
@@ -0,0 +1,339 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type Mock,
type Mocked,
} from 'vitest';
import { GeminiAgent } from './acpRpcDispatcher.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
type Config,
type MessageBus,
type Storage,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { loadSettings, SettingScope } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
describe('GeminiAgent - RPC Dispatcher', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let agent: GeminiAgent;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn(),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGeminiClient: vi.fn().mockReturnValue({
startChat: vi.fn().mockResolvedValue({}),
}),
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus,
storage: {
getWorkspaceAutoSavedPolicyPath: vi.fn(),
getAutoSavedPolicyPath: vi.fn(),
} as unknown as Storage,
get config() {
return this;
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: 'login_with_google' } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn(),
requestPermission: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: {
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
enablePermanentToolApproval: true,
},
mcpServers: {},
},
setValue: vi.fn(),
}));
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
});
it('should initialize correctly', async () => {
const response = await agent.initialize({
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
protocolVersion: 1,
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(4);
const gatewayAuth = response.authMethods?.find(
(m) => m.id === AuthType.GATEWAY,
);
expect(gatewayAuth?._meta).toEqual({
gateway: {
protocol: 'google',
restartRequired: 'false',
},
});
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
expect(geminiAuth?._meta).toEqual({
'api-key': {
provider: 'google',
},
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should authenticate correctly', async () => {
await agent.authenticate({
methodId: AuthType.LOGIN_WITH_GOOGLE,
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should authenticate correctly with api-key in _meta', async () => {
await agent.authenticate({
methodId: AuthType.USE_GEMINI,
_meta: {
'api-key': 'test-api-key',
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.USE_GEMINI,
);
});
it('should authenticate correctly with gateway method', async () => {
await agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.GATEWAY,
undefined,
'https://example.com',
{ Authorization: 'Bearer token' },
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.GATEWAY,
);
});
it('should throw acp.RequestError when gateway payload is malformed', async () => {
await expect(
agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 123,
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest),
).rejects.toThrow(/Malformed gateway payload/);
});
it('should cancel a session', async () => {
const mockSession = {
cancelPendingPrompt: vi.fn(),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
await agent.cancel({ sessionId: 'test-session-id' });
expect(mockSession.cancelPendingPrompt).toHaveBeenCalled();
});
it('should throw error when cancelling non-existent session', async () => {
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(undefined),
};
await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
'Session not found',
);
});
it('should delegate prompt to session', async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
const result = await agent.prompt({
sessionId: 'test-session-id',
prompt: [],
});
expect(mockSession.prompt).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should delegate setMode to session', async () => {
const mockSession = {
setMode: vi.fn().mockReturnValue({}),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
const result = await agent.setSessionMode({
sessionId: 'test-session-id',
modeId: 'plan',
});
expect(mockSession.setMode).toHaveBeenCalledWith('plan');
expect(result).toEqual({});
});
it('should throw error when setting mode on non-existent session', async () => {
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(undefined),
};
await expect(
agent.setSessionMode({
sessionId: 'unknown',
modeId: 'plan',
}),
).rejects.toThrow('Session not found: unknown');
});
it('should delegate setModel to session (unstable)', async () => {
const mockSession = {
setModel: vi.fn().mockReturnValue({}),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
const result = await agent.unstable_setSessionModel({
sessionId: 'test-session-id',
modelId: 'gemini-2.0-pro-exp',
});
expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
expect(result).toEqual({});
});
it('should throw error when setting model on non-existent session (unstable)', async () => {
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(undefined),
};
await expect(
agent.unstable_setSessionModel({
sessionId: 'unknown',
modelId: 'gemini-2.0-pro-exp',
}),
).rejects.toThrow('Session not found: unknown');
});
});
+236
View File
@@ -0,0 +1,236 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type AgentLoopContext,
AuthType,
clearCachedCredentialFile,
getVersion,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { z } from 'zod';
import { SettingScope, type LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js';
import { hasMeta } from './acpUtils.js';
export class GeminiAgent {
private apiKey: string | undefined;
private baseUrl: string | undefined;
private customHeaders: Record<string, string> | undefined;
private sessionManager: AcpSessionManager;
constructor(
private context: AgentLoopContext,
private settings: LoadedSettings,
argv: CliArgs,
connection: acp.AgentSideConnection,
) {
this.sessionManager = new AcpSessionManager(settings, argv, connection);
}
dispose(): void {
this.sessionManager.dispose();
}
async initialize(
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
if (args.clientCapabilities) {
this.sessionManager.setClientCapabilities(args.clientCapabilities);
}
const authMethods = [
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
description: 'Log in with your Google account',
},
{
id: AuthType.USE_GEMINI,
name: 'Gemini API key',
description: 'Use an API key with Gemini Developer API',
_meta: {
'api-key': {
provider: 'google',
},
},
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
{
id: AuthType.GATEWAY,
name: 'AI API Gateway',
description: 'Use a custom AI API Gateway',
_meta: {
gateway: {
protocol: 'google',
restartRequired: 'false',
},
},
},
];
await this.context.config.initialize();
const version = await getVersion();
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentInfo: {
name: 'gemini-cli',
title: 'Gemini CLI',
version,
},
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
},
};
}
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
const { methodId } = req;
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();
}
// Check for api-key in _meta
const meta = hasMeta(req) ? req._meta : undefined;
const apiKey =
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
// or perform new authentication if needed
try {
if (apiKey) {
this.apiKey = apiKey;
}
// Extract gateway details if present
const gatewaySchema = z.object({
baseUrl: z.string().optional(),
headers: z.record(z.string()).optional(),
});
let baseUrl: string | undefined;
let headers: Record<string, string> | undefined;
if (meta?.['gateway']) {
const result = gatewaySchema.safeParse(meta['gateway']);
if (result.success) {
baseUrl = result.data.baseUrl;
headers = result.data.headers;
} else {
throw new acp.RequestError(
-32602,
`Malformed gateway payload: ${result.error.message}`,
);
}
}
this.baseUrl = baseUrl;
this.customHeaders = headers;
await this.context.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl,
headers,
);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
}
private getAuthDetails(): AuthDetails {
return {
apiKey: this.apiKey,
baseUrl: this.baseUrl,
customHeaders: this.customHeaders,
};
}
async newSession(
params: acp.NewSessionRequest,
): Promise<acp.NewSessionResponse> {
return this.sessionManager.newSession(params, this.getAuthDetails());
}
async loadSession(
params: acp.LoadSessionRequest,
): Promise<acp.LoadSessionResponse> {
return this.sessionManager.loadSession(params, this.getAuthDetails());
}
async cancel(params: acp.CancelNotification): Promise<void> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
await session.cancelPendingPrompt();
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
return session.prompt(params);
}
async setSessionMode(
params: acp.SetSessionModeRequest,
): Promise<acp.SetSessionModeResponse> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
return session.setMode(params.modeId);
}
async unstable_setSessionModel(
params: acp.SetSessionModelRequest,
): Promise<acp.SetSessionModelResponse> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
return session.setModel(params.modelId);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,382 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
type Mocked,
} from 'vitest';
import { AcpSessionManager } from './acpSessionManager.js';
import type * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
type Config,
GEMINI_MODEL_ALIAS_AUTO,
type MessageBus,
type Storage,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
const startAutoMemoryIfEnabledMock = vi.fn();
vi.mock('../utils/autoMemory.js', () => ({
startAutoMemoryIfEnabled: (config: Config) =>
startAutoMemoryIfEnabledMock(config),
}));
describe('AcpSessionManager', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let manager: AcpSessionManager;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn(),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGeminiClient: vi.fn().mockReturnValue({
startChat: vi.fn().mockResolvedValue({}),
}),
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus,
storage: {
getWorkspaceAutoSavedPolicyPath: vi.fn(),
getAutoSavedPolicyPath: vi.fn(),
} as unknown as Storage,
get config() {
return this;
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: 'login_with_google' } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn(),
requestPermission: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: {
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
enablePermanentToolApproval: true,
},
mcpServers: {},
},
setValue: vi.fn(),
}));
manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection);
vi.mock('node:crypto', () => ({
randomUUID: () => 'test-session-id',
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create a new session', async () => {
vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.sessionId).toBe('test-session-id');
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
// Verify deferred call (sendAvailableCommands)
await vi.runAllTimersAsync();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
}),
}),
);
vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.modes).toEqual({
availableModes: [
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
{
id: 'autoEdit',
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
],
currentModeId: 'default',
});
});
it('should include preview models when user has access', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: GEMINI_MODEL_ALIAS_AUTO,
name: expect.stringContaining('Auto'),
}),
]),
);
});
it('should NOT include retired preview models (none) in available models', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
const modelIds =
response.models?.availableModels?.map((m) => m.modelId) ?? [];
expect(modelIds).not.toContain('none');
});
it('should return modes with plan mode when plan is enabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.modes).toEqual({
availableModes: [
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
{
id: 'autoEdit',
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
{ id: 'plan', name: 'Plan', description: 'Read-only mode' },
],
currentModeId: 'plan',
});
});
it('should fail session creation if Gemini API key is missing', async () => {
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: { auth: { selectedType: AuthType.USE_GEMINI } },
mcpServers: {},
},
setValue: vi.fn(),
}));
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: undefined,
});
await expect(
manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
),
).rejects.toMatchObject({
message: 'Gemini API key is missing or not configured.',
});
});
it('should create a new session with mcp servers', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
const mcpServers = [
{
name: 'test-server',
command: 'node',
args: ['server.js'],
env: [{ name: 'KEY', value: 'VALUE' }],
},
];
await manager.newSession(
{
cwd: '/tmp',
mcpServers,
},
{},
);
expect(loadCliConfig).toHaveBeenCalledWith(
expect.objectContaining({
mcpServers: expect.objectContaining({
'test-server': expect.objectContaining({
command: 'node',
args: ['server.js'],
env: { KEY: 'VALUE' },
}),
}),
}),
'test-session-id',
mockArgv,
{ cwd: '/tmp' },
);
});
it('should handle authentication failure gracefully', async () => {
mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
await expect(
manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
),
).rejects.toMatchObject({
message: 'Auth failed',
});
});
it('should initialize file system service if client supports it', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
manager.setClientCapabilities({
fs: { readTextFile: true, writeTextFile: true },
});
await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(mockConfig.setFileSystemService).toHaveBeenCalled();
});
it('should start auto memory for new ACP sessions', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig);
});
});
+343
View File
@@ -0,0 +1,343 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
AuthType,
MCPServerConfig,
debugLogger,
startupProfiler,
convertSessionToClientHistory,
createPolicyUpdater,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { randomUUID } from 'node:crypto';
import { loadSettings, type LoadedSettings } from '../config/settings.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { Session } from './acpSession.js';
import { AcpFileSystemService } from './acpFileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { buildAvailableModels, buildAvailableModes } from './acpUtils.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
export interface AuthDetails {
apiKey?: string;
baseUrl?: string;
customHeaders?: Record<string, string>;
}
export class AcpSessionManager {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
constructor(
private settings: LoadedSettings,
private argv: CliArgs,
private connection: acp.AgentSideConnection,
) {}
setClientCapabilities(capabilities: acp.ClientCapabilities) {
this.clientCapabilities = capabilities;
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
dispose(): void {
for (const session of this.sessions.values()) {
session.dispose();
}
this.sessions.clear();
}
async newSession(
{ cwd, mcpServers }: acp.NewSessionRequest,
authDetails: AuthDetails,
): 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 ||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
? AuthType.GATEWAY
: AuthType.USE_GEMINI);
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(
authType,
authDetails.apiKey,
authDetails.baseUrl,
authDetails.customHeaders,
);
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(
-32000,
authErrorMessage || 'Authentication required.',
);
}
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
loadedSettings,
);
const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
async loadSession(
{ sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
authDetails: AuthDetails,
): Promise<acp.LoadSessionResponse> {
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
authDetails,
);
const sessionSelector = new SessionSelector(config.storage);
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
const clientHistory = convertSessionToClientHistory(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.settings,
);
const existingSession = this.sessions.get(sessionId);
if (existingSession) {
existingSession.dispose();
}
this.sessions.set(sessionId, session);
// Stream history back to client
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
this.settings,
);
const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
private async initializeSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
authDetails: AuthDetails,
): Promise<Config> {
const selectedAuthType =
this.settings.merged.security.auth.selectedType ||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
? AuthType.GATEWAY
: undefined);
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,
authDetails.apiKey,
authDetails.baseUrl,
authDetails.customHeaders,
);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
// 3. Set the ACP FileSystemService (if supported) before config initialization
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
// 4. 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);
startAutoMemoryIfEnabled(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 });
createPolicyUpdater(
config.getPolicyEngine(),
config.messageBus,
config.storage,
);
return config;
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config, createWorkingStdio } from '@google/gemini-cli-core';
import { runExitCleanup } from '../utils/cleanup.js';
import * as acp from '@agentclientprotocol/sdk';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
import { GeminiAgent } from './acpRpcDispatcher.js';
export async function runAcpClient(
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);
}
+365
View File
@@ -0,0 +1,365 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type ToolResult,
type ToolCallConfirmationDetails,
Kind,
ApprovalMode,
GEMINI_MODEL_ALIAS_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
getDisplayString,
AuthType,
ToolConfirmationOutcome,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import { z } from 'zod';
import type { LoadedSettings } from '../config/settings.js';
export function hasMeta(
obj: unknown,
): obj is { _meta?: Record<string, unknown> } {
return typeof obj === 'object' && obj !== null && '_meta' in obj;
}
export const RequestPermissionResponseSchema = z.object({
outcome: z.discriminatedUnion('outcome', [
z.object({ outcome: z.literal('cancelled') }),
z.object({
outcome: z.literal('selected'),
optionId: z.string(),
}),
]),
});
export 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.filePath ??
toolResult.returnDisplay.fileName,
oldText: toolResult.returnDisplay.originalContent,
newText: toolResult.returnDisplay.newContent,
_meta: {
kind: !toolResult.returnDisplay.originalContent
? 'add'
: toolResult.returnDisplay.newContent === ''
? 'delete'
: 'modify',
},
};
}
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;
export function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
config: Config,
enablePermanentToolApproval: boolean = false,
): acp.PermissionOption[] {
const disableAlwaysAllow = config.getDisableAlwaysAllow();
const options: acp.PermissionOption[] = [];
if (!disableAlwaysAllow) {
switch (confirmation.type) {
case 'edit':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for this file in all future sessions',
kind: 'allow_always',
});
}
break;
case 'exec':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow this command for all future sessions',
kind: 'allow_always',
});
}
break;
case 'mcp':
options.push(
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: 'Allow all server tools for this session',
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: 'Allow tool for this session',
kind: 'allow_always',
},
);
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow tool for all future sessions',
kind: 'allow_always',
});
}
break;
case 'info':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for all future sessions',
kind: 'allow_always',
});
}
break;
case 'ask_user':
case 'exit_plan_mode':
// askuser and exit_plan_mode don't need "always allow" options
break;
default:
// No "always allow" options for other types
break;
}
}
options.push(...basicPermissionOptions);
// Exhaustive check
switch (confirmation.type) {
case 'edit':
case 'exec':
case 'mcp':
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
return options;
}
export function toAcpToolKind(kind: Kind): acp.ToolKind {
switch (kind) {
case Kind.Read:
case Kind.Edit:
case Kind.Execute:
case Kind.Search:
case Kind.Delete:
case Kind.Move:
case Kind.Think:
case Kind.Fetch:
case Kind.SwitchMode:
case Kind.Other:
return kind as acp.ToolKind;
case Kind.Agent:
return 'think';
case Kind.Plan:
case Kind.Communicate:
default:
return 'other';
}
}
export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
const modes: acp.SessionMode[] = [
{
id: ApprovalMode.DEFAULT,
name: 'Default',
description: 'Prompts for approval',
},
{
id: ApprovalMode.AUTO_EDIT,
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{
id: ApprovalMode.YOLO,
name: 'YOLO',
description: 'Auto-approves all tools',
},
];
if (isPlanEnabled) {
modes.push({
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
});
}
return modes;
}
export function buildAvailableModels(
config: Config,
settings: LoadedSettings,
): {
availableModels: Array<{
modelId: string;
name: string;
description?: string;
}>;
currentModelId: string;
} {
const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini3_5Flash = config.hasGemini35FlashGAAccess?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
// --- DYNAMIC PATH ---
if (
config.getExperimentalDynamicModelConfiguration?.() === true &&
config.getModelConfigService
) {
const options = config.getModelConfigService().getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_5Flash,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
});
return {
availableModels: options,
currentModelId: preferredModel,
};
}
// --- LEGACY PATH ---
const mainOptions = [
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
useGemini3_5Flash,
),
},
];
const manualOptions = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
},
];
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
},
];
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
previewOptions.push({
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
});
}
manualOptions.unshift(...previewOptions);
}
const scaleOptions = (
options: Array<{ value: string; title: string; description?: string }>,
) =>
options.map((o) => ({
modelId: o.value,
name: o.title,
description: o.description,
}));
return {
availableModels: [
...scaleOptions(mainOptions),
...scaleOptions(manualOptions),
],
currentModelId: preferredModel,
};
}
+74
View File
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
IdeClient,
UserAccountManager,
getVersion,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import process from 'node:process';
export class AboutCommand implements Command {
readonly name = 'about';
readonly description = 'Show version and environment info';
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const osVersion = process.platform;
let sandboxEnv = 'no sandbox';
if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') {
sandboxEnv = process.env['SANDBOX'];
} else if (process.env['SANDBOX'] === 'sandbox-exec') {
sandboxEnv = `sandbox-exec (${
process.env['SEATBELT_PROFILE'] || 'unknown'
})`;
}
const modelVersion = context.agentContext.config.getModel() || 'Unknown';
const cliVersion = await getVersion();
const selectedAuthType =
context.settings.merged?.security?.auth?.selectedType ?? '';
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
const ideClient = await getIdeClientName(context);
const userAccountManager = new UserAccountManager();
const cachedAccount = userAccountManager.getCachedGoogleAccount();
const userEmail = cachedAccount ?? 'Unknown';
const tier = context.agentContext.config.getUserTierName() || 'Unknown';
const info = [
`- Version: ${cliVersion}`,
`- OS: ${osVersion}`,
`- Sandbox: ${sandboxEnv}`,
`- Model: ${modelVersion}`,
`- Auth Type: ${selectedAuthType}`,
`- GCP Project: ${gcpProject}`,
`- IDE Client: ${ideClient}`,
`- User Email: ${userEmail}`,
`- Tier: ${tier}`,
].join('\n');
return {
name: this.name,
data: `Gemini CLI Info:\n${info}`,
};
}
}
async function getIdeClientName(context: CommandContext) {
if (!context.agentContext.config.getIdeMode()) {
return '';
}
const ideClient = await IdeClient.getInstance();
return ideClient?.getDetectedIdeDisplayName() ?? '';
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Command } from './types.js';
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { CommandContext } from './types.js';
import {
DisableExtensionCommand,
UninstallExtensionCommand,
} from './extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js';
const mockGetErrorMessage = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getErrorMessage: mockGetErrorMessage,
};
});
vi.mock('../../config/extension-manager.js', () => {
class MockExtensionManager {
disableExtension = vi.fn(async () => undefined);
uninstallExtension = vi.fn(async () => undefined);
getExtensions = vi.fn(() => []);
}
return {
ExtensionManager: MockExtensionManager,
inferInstallMetadata: vi.fn(),
};
});
type TestExtensionManager = InstanceType<typeof ExtensionManager> & {
disableExtension: ReturnType<typeof vi.fn>;
uninstallExtension: ReturnType<typeof vi.fn>;
getExtensions: ReturnType<typeof vi.fn>;
};
function createExtensionManager(): TestExtensionManager {
return new ExtensionManager({} as never) as TestExtensionManager;
}
function createContext(extensionLoader: unknown): CommandContext {
return {
agentContext: {
config: {
getExtensionLoader: vi.fn().mockReturnValue(extensionLoader),
},
} as unknown as CommandContext['agentContext'],
settings: {} as CommandContext['settings'],
sendMessage: vi.fn().mockResolvedValue(undefined),
};
}
describe('ACP extensions error paths', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetErrorMessage.mockImplementation((error: unknown) =>
error instanceof Error ? error.message : String(error),
);
});
it('returns error when disabling fails', async () => {
const command = new DisableExtensionCommand();
const extensionManager = createExtensionManager();
extensionManager.disableExtension.mockRejectedValue(
new Error('Extension not found.'),
);
const context = createContext(extensionManager);
const result = await command.execute(context, ['missing-ext']);
expect(result).toEqual({
name: 'extensions disable',
data: 'Failed to disable "missing-ext": Extension not found.',
});
});
it('returns error when uninstalling a non-existent extension', async () => {
const command = new UninstallExtensionCommand();
const extensionManager = createExtensionManager();
extensionManager.uninstallExtension.mockRejectedValue(
new Error('Extension not found.'),
);
const context = createContext(extensionManager);
const result = await command.execute(context, ['non-existent-ext']);
expect(result).toEqual({
name: 'extensions uninstall',
data: 'Failed to uninstall extension "non-existent-ext": Extension not found.',
});
});
});
+448
View File
@@ -0,0 +1,448 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
listExtensions,
type Config,
getErrorMessage,
} from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [
new ListExtensionsCommand(),
new ExploreExtensionsCommand(),
new EnableExtensionCommand(),
new DisableExtensionCommand(),
new InstallExtensionCommand(),
new LinkExtensionCommand(),
new UninstallExtensionCommand(),
new RestartExtensionCommand(),
new UpdateExtensionCommand(),
];
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(context, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.agentContext.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
export class ExploreExtensionsCommand implements Command {
readonly name = 'extensions explore';
readonly description = 'Explore available extensions.';
async execute(
_context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensionsUrl = 'https://geminicli.com/extensions/';
return {
name: this.name,
data: `View or install available extensions at ${extensionsUrl}`,
};
}
}
function getEnableDisableContext(
config: Config,
args: string[],
invocationName: string,
) {
const extensionManager = config.getExtensionLoader();
if (!(extensionManager instanceof ExtensionManager)) {
return {
error: `Cannot ${invocationName} extensions in this environment.`,
};
}
if (args.length === 0) {
return {
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
};
}
let scope = SettingScope.User;
if (args.includes('--scope=workspace') || args.includes('workspace')) {
scope = SettingScope.Workspace;
} else if (args.includes('--scope=session') || args.includes('session')) {
scope = SettingScope.Session;
}
const name = args.filter(
(a) =>
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
)[0];
let names: string[] = [];
if (name === '--all') {
let extensions = extensionManager.getExtensions();
if (invocationName === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (invocationName === 'disable') {
extensions = extensions.filter((ext) => ext.isActive);
}
names = extensions.map((ext) => ext.name);
} else if (name) {
names = [name];
} else {
return { error: 'No extension name provided.' };
}
return { extensionManager, names, scope };
}
export class EnableExtensionCommand implements Command {
readonly name = 'extensions enable';
readonly description = 'Enable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.agentContext.config,
args,
'enable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.enableExtension(name, scope);
output.push(`Extension "${name}" enabled for scope "${scope}".`);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === name);
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpClientManager =
context.agentContext.config.getMcpClientManager();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers),
);
if (mcpClientManager && enabledServers.length > 0) {
const restartPromises = enabledServers.map((serverName) =>
mcpClientManager.restartServer(serverName).catch((error) => {
output.push(
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
);
}),
);
await Promise.all(restartPromises);
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
}
}
} catch (e) {
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class DisableExtensionCommand implements Command {
readonly name = 'extensions disable';
readonly description = 'Disable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.agentContext.config,
args,
'disable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.disableExtension(name, scope);
output.push(`Extension "${name}" disabled for scope "${scope}".`);
} catch (e) {
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class InstallExtensionCommand implements Command {
readonly name = 'extensions install';
readonly description = 'Install an extension from a git repo or local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot install extensions in this environment.',
};
}
const source = args.join(' ').trim();
if (!source) {
return { name: this.name, data: `Usage: /extensions install <source>` };
}
if (/[;&|`'"]/.test(source)) {
return {
name: this.name,
data: `Invalid source: contains disallowed characters.`,
};
}
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
return {
name: this.name,
data: `Extension "${extension.name}" installed successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
};
}
}
}
export class LinkExtensionCommand implements Command {
readonly name = 'extensions link';
readonly description = 'Link an extension from a local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot link extensions in this environment.',
};
}
const sourceFilepath = args.join(' ').trim();
if (!sourceFilepath) {
return { name: this.name, data: `Usage: /extensions link <source>` };
}
try {
await stat(sourceFilepath);
} catch {
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
}
try {
const extension = await extensionLoader.installOrUpdateExtension({
source: sourceFilepath,
type: 'link',
});
return {
name: this.name,
data: `Extension "${extension.name}" linked successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to link extension: ${getErrorMessage(error)}`,
};
}
}
}
export class UninstallExtensionCommand implements Command {
readonly name = 'extensions uninstall';
readonly description = 'Uninstall an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot uninstall extensions in this environment.',
};
}
const all = args.includes('--all');
const names = args.filter((a) => !a.startsWith('--')).map((a) => a.trim());
if (!all && names.length === 0) {
return {
name: this.name,
data: `Usage: /extensions uninstall <extension-names...>|--all`,
};
}
let namesToUninstall: string[] = [];
if (all) {
namesToUninstall = extensionLoader.getExtensions().map((ext) => ext.name);
} else {
namesToUninstall = names;
}
if (namesToUninstall.length === 0) {
return {
name: this.name,
data: all ? 'No extensions installed.' : 'No extension name provided.',
};
}
const output: string[] = [];
for (const extensionName of namesToUninstall) {
try {
await extensionLoader.uninstallExtension(extensionName, false);
output.push(`Extension "${extensionName}" uninstalled successfully.`);
} catch (error) {
output.push(
`Failed to uninstall extension "${extensionName}": ${getErrorMessage(error)}`,
);
}
}
return { name: this.name, data: output.join('\n') };
}
}
export class RestartExtensionCommand implements Command {
readonly name = 'extensions restart';
readonly description = 'Restart an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot restart extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions restart <extension-names>|--all',
};
}
let extensionsToRestart = extensionLoader
.getExtensions()
.filter((e) => e.isActive);
if (names) {
extensionsToRestart = extensionsToRestart.filter((e) =>
names.includes(e.name),
);
}
if (extensionsToRestart.length === 0) {
return {
name: this.name,
data: 'No active extensions matched the request.',
};
}
const output: string[] = [];
for (const extension of extensionsToRestart) {
try {
await extensionLoader.restartExtension(extension);
output.push(`Restarted "${extension.name}".`);
} catch (e) {
output.push(
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
);
}
}
return { name: this.name, data: output.join('\n') };
}
}
export class UpdateExtensionCommand implements Command {
readonly name = 'extensions update';
readonly description = 'Update an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.agentContext.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot update extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions update <extension-names>|--all',
};
}
return {
name: this.name,
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
};
}
}
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { HelpCommand } from './help.js';
import { CommandRegistry } from './commandRegistry.js';
import type { Command, CommandContext } from './types.js';
describe('HelpCommand', () => {
it('returns formatted help text with sorted commands', async () => {
const registry = new CommandRegistry();
const cmdB: Command = {
name: 'bravo',
description: 'Bravo command',
execute: async () => ({ name: 'bravo', data: '' }),
};
const cmdA: Command = {
name: 'alpha',
description: 'Alpha command',
execute: async () => ({ name: 'alpha', data: '' }),
};
registry.register(cmdB);
registry.register(cmdA);
const helpCommand = new HelpCommand(registry);
const context = {} as CommandContext;
const response = await helpCommand.execute(context, []);
expect(response.name).toBe('help');
const data = response.data as string;
expect(data).toContain('Gemini CLI Help:');
expect(data).toContain('### Basics');
expect(data).toContain('### Commands');
const lines = data.split('\n');
const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
expect(alphaIndex).toBeLessThan(bravoIndex);
expect(alphaIndex).not.toBe(-1);
expect(bravoIndex).not.toBe(-1);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { CommandRegistry } from './commandRegistry.js';
export class HelpCommand implements Command {
readonly name = 'help';
readonly description = 'Show available commands';
constructor(private registry: CommandRegistry) {}
async execute(
_context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const commands = this.registry
.getAllCommands()
.sort((a, b) => a.name.localeCompare(b.name));
const lines: string[] = [];
lines.push('Gemini CLI Help:');
lines.push('');
lines.push('### Basics');
lines.push(
'- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
);
lines.push('');
lines.push('### Commands');
for (const cmd of commands) {
if (cmd.description) {
lines.push(`- **/${cmd.name}** - ${cmd.description}`);
}
}
return {
name: this.name,
data: lines.join('\n'),
};
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const targetDir = context.agentContext.config.getTargetDir();
if (!targetDir) {
throw new Error('Command requires a workspace.');
}
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const result = performInit(fs.existsSync(geminiMdPath));
switch (result.type) {
case 'message':
return {
name: this.name,
data: result,
};
case 'submit_prompt':
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
// We output the prompt text they can use to re-trigger the generation manually,
// or just seed the GEMINI.md file as we've done above.
return {
name: this.name,
data: {
type: 'message',
messageType: 'info',
content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
},
};
default:
throw new Error('Unknown result type from performInit');
}
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new InboxMemoryCommand(),
];
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.agentContext.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly aliases = ['memory reload'];
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.agentContext.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.agentContext.config);
return { name: this.name, data: result.content };
}
}
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
'Lists memory items extracted from past sessions that are pending review.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
if (!context.agentContext.config.isAutoMemoryEnabled()) {
return {
name: this.name,
data: 'The memory inbox requires Auto Memory. Enable it with: experimental.autoMemory = true in settings.',
};
}
const [skills, patches, memoryPatches] = await Promise.all([
listInboxSkills(context.agentContext.config),
listInboxPatches(context.agentContext.config),
listInboxMemoryPatches(context.agentContext.config),
]);
if (
skills.length === 0 &&
patches.length === 0 &&
memoryPatches.length === 0
) {
return { name: this.name, data: 'No items in inbox.' };
}
const lines: string[] = [];
for (const s of skills) {
const date = s.extractedAt
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
: '';
lines.push(`- **${s.name}**: ${s.description}${date}`);
}
for (const p of patches) {
const targets = p.entries.map((e) => e.targetPath).join(', ');
const date = p.extractedAt
? ` (extracted: ${new Date(p.extractedAt).toLocaleDateString()})`
: '';
lines.push(`- **${p.name}** (update): patches ${targets}${date}`);
}
for (const memoryPatch of memoryPatches) {
const targets = memoryPatch.entries.map((e) => e.targetPath).join(', ');
const date = memoryPatch.extractedAt
? ` (latest extract: ${new Date(memoryPatch.extractedAt).toLocaleDateString()})`
: '';
const sourceCount = memoryPatch.sourceFiles.length;
const sourceLabel = sourceCount === 1 ? 'patch' : 'patches';
lines.push(
`- **${memoryPatch.name}** (${sourceCount} source ${sourceLabel}, ${memoryPatch.entries.length} hunks): targets ${targets}${date}`,
);
}
const total = skills.length + patches.length + memoryPatches.length;
return {
name: this.name,
data: `Memory inbox (${total}):\n${lines.join('\n')}`,
};
}
}
@@ -0,0 +1,244 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
import * as fs from 'node:fs/promises';
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import type { CommandContext } from './types.js';
import type { Mock } from 'vitest';
vi.mock('node:fs/promises');
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getCheckpointInfoList: vi.fn(),
getToolCallDataSchema: vi.fn(),
isNodeError: vi.fn(),
performRestore: vi.fn(),
};
});
describe('RestoreCommand', () => {
let context: CommandContext;
let restoreCommand: RestoreCommand;
beforeEach(() => {
vi.resetAllMocks();
restoreCommand = new RestoreCommand();
context = {
agentContext: {
config: {
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
storage: {
getProjectTempCheckpointsDir: vi
.fn()
.mockReturnValue('/tmp/checkpoints'),
},
},
},
git: {},
sendMessage: vi.fn(),
} as unknown as CommandContext;
});
it('delegates to list behavior when invoked without args', async () => {
const listExecuteSpy = vi
.spyOn(ListCheckpointsCommand.prototype, 'execute')
.mockResolvedValue({
name: 'restore list',
data: 'list data',
});
const response = await restoreCommand.execute(context, []);
expect(listExecuteSpy).toHaveBeenCalledWith(context);
expect(response).toEqual({
name: 'restore list',
data: 'list data',
});
});
it('returns checkpointing-disabled message when disabled', async () => {
(
context.agentContext.config.getCheckpointingEnabled as Mock
).mockReturnValue(false);
const response = await restoreCommand.execute(context, ['checkpoint1']);
expect(response.data).toContain('Checkpointing is not enabled');
});
it('returns file-not-found message for missing checkpoint', async () => {
const error = new Error('ENOENT');
(error as Error & { code: string }).code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(isNodeError).mockReturnValue(true);
const response = await restoreCommand.execute(context, ['missing']);
expect(response.data).toBe('File not found: missing.json');
});
it('handles checkpoint filename already ending in .json', async () => {
const error = new Error('ENOENT');
(error as Error & { code: string }).code = 'ENOENT';
vi.mocked(fs.readFile).mockRejectedValue(error);
vi.mocked(isNodeError).mockReturnValue(true);
const response = await restoreCommand.execute(context, ['existing.json']);
expect(response.data).toBe('File not found: existing.json');
expect(fs.readFile).toHaveBeenCalledWith(
expect.stringContaining('existing.json'),
'utf-8',
);
});
it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
vi.mocked(getToolCallDataSchema).mockReturnValue({
safeParse: vi.fn().mockReturnValue({ success: false }),
} as unknown as ReturnType<typeof getToolCallDataSchema>);
const response = await restoreCommand.execute(context, ['invalid']);
expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
});
it('formats streamed restore results correctly', async () => {
vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
vi.mocked(getToolCallDataSchema).mockReturnValue({
safeParse: vi
.fn()
.mockReturnValue({ success: true, data: { some: 'data' } }),
} as unknown as ReturnType<typeof getToolCallDataSchema>);
async function* mockRestoreGenerator() {
yield { type: 'message', messageType: 'info', content: 'Restoring...' };
yield { type: 'load_history', clientHistory: [{}, {}] };
yield { type: 'other', some: 'other' };
}
vi.mocked(performRestore).mockReturnValue(
mockRestoreGenerator() as unknown as ReturnType<typeof performRestore>,
);
const response = await restoreCommand.execute(context, ['valid']);
expect(response.data).toContain('[INFO] Restoring...');
expect(response.data).toContain('Loaded history with 2 messages.');
expect(response.data).toContain(
'Restored: {"type":"other","some":"other"}',
);
});
it('returns generic unexpected error message for non-ENOENT failures', async () => {
vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
vi.mocked(isNodeError).mockReturnValue(false);
const response = await restoreCommand.execute(context, ['error']);
expect(response.data).toContain(
'An unexpected error occurred during restore: Error: Random error',
);
});
});
describe('ListCheckpointsCommand', () => {
let context: CommandContext;
let listCommand: ListCheckpointsCommand;
let mockReaddir: Mock<(path: string) => Promise<string[]>>;
beforeEach(() => {
vi.resetAllMocks();
listCommand = new ListCheckpointsCommand();
mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
(path: string) => Promise<string[]>
>;
context = {
agentContext: {
config: {
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
storage: {
getProjectTempCheckpointsDir: vi
.fn()
.mockReturnValue('/tmp/checkpoints'),
},
},
},
} as unknown as CommandContext;
});
it('returns checkpointing-disabled message when disabled', async () => {
(
context.agentContext.config.getCheckpointingEnabled as Mock
).mockReturnValue(false);
const response = await listCommand.execute(context);
expect(response.data).toContain('Checkpointing is not enabled');
});
it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
const response = await listCommand.execute(context);
expect(response.data).toBe('No checkpoints found.');
});
it('ignores error when mkdir fails', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
mockReaddir.mockResolvedValue([]);
const response = await listCommand.execute(context);
expect(response.data).toBe('No checkpoints found.');
expect(fs.mkdir).toHaveBeenCalled();
});
it('formats checkpoint summary output from checkpoint metadata', async () => {
mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
vi.mocked(getCheckpointInfoList).mockReturnValue([
{ messageId: 'id1', checkpoint: 'cp1' },
{ messageId: 'id2', checkpoint: 'cp2' },
]);
const response = await listCommand.execute(context);
expect(response.data).toContain('Available Checkpoints:');
// Note: The current implementation of ListCheckpointsCommand incorrectly accesses
// fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
});
it('handles empty checkpoint info list', async () => {
mockReaddir.mockResolvedValue(['some.json']);
vi.mocked(getCheckpointInfoList).mockReturnValue([]);
const response = await listCommand.execute(context);
expect(response.data).toBe('Available Checkpoints:\n');
});
it('returns generic unexpected error message on failures', async () => {
mockReaddir.mockRejectedValue(new Error('Readdir fail'));
const response = await listCommand.execute(context);
expect(response.data).toBe(
'An unexpected error occurred while listing checkpoints.',
);
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class RestoreCommand implements Command {
readonly name = 'restore';
readonly description =
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
readonly requiresWorkspace = true;
readonly subCommands = [new ListCheckpointsCommand()];
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { agentContext: agentContext, git: gitService } = context;
const { config } = agentContext;
const argsStr = args.join(' ');
try {
if (!argsStr) {
return await new ListCheckpointsCommand().execute(context);
}
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const selectedFile = argsStr.endsWith('.json')
? argsStr
: `${argsStr}.json`;
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const filePath = path.join(checkpointDir, selectedFile);
let data: string;
try {
data = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
name: this.name,
data: `File not found: ${selectedFile}`,
};
}
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
if (!parseResult.success) {
return {
name: this.name,
data: 'Checkpoint file is invalid or corrupted.',
};
}
const restoreResultGenerator = performRestore(
parseResult.data,
gitService,
);
const restoreResult = [];
for await (const result of restoreResultGenerator) {
restoreResult.push(result);
}
// Format the result nicely since Zed just dumps data
const formattedResult = restoreResult
.map((r) => {
if (r.type === 'message') {
return `[${r.messageType.toUpperCase()}] ${r.content}`;
} else if (r.type === 'load_history') {
return `Loaded history with ${r.clientHistory.length} messages.`;
}
return `Restored: ${JSON.stringify(r)}`;
})
.join('\n');
return {
name: this.name,
data: formattedResult,
};
} catch (error) {
return {
name: this.name,
data: `An unexpected error occurred during restore: ${error}`,
};
}
}
}
export class ListCheckpointsCommand implements Command {
readonly name = 'restore list';
readonly description = 'Lists all available checkpoints.';
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context.agentContext;
try {
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
} catch {
// Ignore
}
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
if (jsonFiles.length === 0) {
return { name: this.name, data: 'No checkpoints found.' };
}
const checkpointFiles = new Map<string, string>();
for (const file of jsonFiles) {
const filePath = path.join(checkpointDir, file);
const data = await fs.readFile(filePath, 'utf-8');
checkpointFiles.set(file, data);
}
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
const formatted = checkpointInfoList
.map((info) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i = info as Record<string, any>;
const fileName = String(i['fileName'] || 'Unknown');
const toolName = String(i['toolName'] || 'Unknown');
const status = String(i['status'] || 'Unknown');
const timestamp = new Date(
Number(i['timestamp']) || 0,
).toLocaleString();
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
})
.join('\n');
return {
name: this.name,
data: `Available Checkpoints:\n${formatted}`,
};
} catch {
return {
name: this.name,
data: 'An unexpected error occurred while listing checkpoints.',
};
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AgentLoopContext, GitService } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
export interface CommandContext {
agentContext: AgentLoopContext;
settings: LoadedSettings;
git?: GitService;
sendMessage: (text: string) => Promise<void>;
}
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly aliases?: string[];
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly requiresWorkspace?: boolean;
execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { extensionsCommand } from './extensions.js';
// Mock subcommands
vi.mock('./extensions/install.js', () => ({
installCommand: { command: 'install' },
}));
vi.mock('./extensions/uninstall.js', () => ({
uninstallCommand: { command: 'uninstall' },
}));
vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } }));
vi.mock('./extensions/update.js', () => ({
updateCommand: { command: 'update' },
}));
vi.mock('./extensions/disable.js', () => ({
disableCommand: { command: 'disable' },
}));
vi.mock('./extensions/enable.js', () => ({
enableCommand: { command: 'enable' },
}));
vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } }));
vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } }));
vi.mock('./extensions/validate.js', () => ({
validateCommand: { command: 'validate' },
}));
// Mock gemini.js
vi.mock('../gemini.js', () => ({
initializeOutputListenersAndFlush: vi.fn(),
}));
describe('extensionsCommand', () => {
it('should have correct command and aliases', () => {
expect(extensionsCommand.command).toBe('extensions <command>');
expect(extensionsCommand.aliases).toEqual(['extension']);
expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.');
});
it('should register all subcommands in builder', () => {
const mockYargs = {
middleware: vi.fn().mockReturnThis(),
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
// @ts-expect-error - Mocking yargs
extensionsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'install' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'uninstall' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'list' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'update' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'disable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'enable' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'link' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'new' }),
);
expect(mockYargs.command).toHaveBeenCalledWith(
expect.objectContaining({ command: 'validate' }),
);
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
it('should have a handler that does nothing', () => {
// @ts-expect-error - Handler doesn't take arguments in this case
expect(extensionsCommand.handler()).toBeUndefined();
});
});
+47
View File
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { installCommand } from './extensions/install.js';
import { uninstallCommand } from './extensions/uninstall.js';
import { listCommand } from './extensions/list.js';
import { updateCommand } from './extensions/update.js';
import { disableCommand } from './extensions/disable.js';
import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { validateCommand } from './extensions/validate.js';
import { configureCommand } from './extensions/configure.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
aliases: ['extension'],
describe: 'Manage Gemini CLI extensions.',
builder: (yargs) =>
yargs
.middleware((argv) => {
initializeOutputListenersAndFlush();
argv['isCommand'] = true;
})
.command(defer(installCommand, 'extensions'))
.command(defer(uninstallCommand, 'extensions'))
.command(defer(listCommand, 'extensions'))
.command(defer(updateCommand, 'extensions'))
.command(defer(disableCommand, 'extensions'))
.command(defer(enableCommand, 'extensions'))
.command(defer(linkCommand, 'extensions'))
.command(defer(newCommand, 'extensions'))
.command(defer(validateCommand, 'extensions'))
.command(defer(configureCommand, 'extensions'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
},
};
@@ -0,0 +1,290 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { configureCommand } from './configure.js';
import yargs from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import {
updateSetting,
getScopedEnvContents,
type ExtensionSetting,
} from '../../config/extensions/extensionSettings.js';
import { cleanupTmpDir } from '@google/gemini-cli-test-utils';
import prompts from 'prompts';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const { mockExtensionManager, mockGetExtensionManager, mockLoadSettings } =
vi.hoisted(() => {
const extensionManager = {
loadExtensionConfig: vi.fn(),
getExtensions: vi.fn(),
loadExtensions: vi.fn(),
getSettings: vi.fn(),
};
return {
mockExtensionManager: extensionManager,
mockGetExtensionManager: vi.fn(),
mockLoadSettings: vi.fn().mockReturnValue({ merged: {} }),
};
});
vi.mock('../../config/extension-manager.js', () => ({
ExtensionManager: vi.fn().mockImplementation(() => mockExtensionManager),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
updateSetting: vi.fn(),
promptForSetting: vi.fn(),
getScopedEnvContents: vi.fn(),
ExtensionSettingScope: {
USER: 'user',
WORKSPACE: 'workspace',
},
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
vi.mock('./utils.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils.js')>();
return {
...actual,
getExtensionManager: mockGetExtensionManager,
};
});
vi.mock('prompts');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
import { ExtensionManager } from '../../config/extension-manager.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: mockLoadSettings,
}));
describe('extensions configure command', () => {
let tempWorkspaceDir: string;
beforeEach(() => {
vi.spyOn(debugLogger, 'log');
vi.spyOn(debugLogger, 'error');
vi.clearAllMocks();
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
// Default behaviors
mockLoadSettings.mockReturnValue({ merged: {} });
mockGetExtensionManager.mockResolvedValue(mockExtensionManager);
(ExtensionManager as unknown as Mock).mockImplementation(
() => mockExtensionManager,
);
});
afterEach(async () => {
await cleanupTmpDir(tempWorkspaceDir);
vi.restoreAllMocks();
});
const runCommand = async (command: string) => {
const parser = yargs().command(configureCommand).help(false).version(false);
await parser.parse(command);
};
const setupExtension = (
name: string,
settings: Array<Partial<ExtensionSetting>> = [],
id = 'test-id',
path = '/test/path',
) => {
const extension = { name, path, id };
mockExtensionManager.getExtensions.mockReturnValue([extension]);
mockExtensionManager.loadExtensionConfig.mockResolvedValue({
name,
settings,
});
return extension;
};
describe('Specific setting configuration', () => {
it('should configure a specific setting', async () => {
setupExtension('test-ext', [
{ name: 'Test Setting', envVar: 'TEST_VAR' },
]);
(updateSetting as Mock).mockResolvedValue(undefined);
await runCommand('config test-ext TEST_VAR');
expect(updateSetting).toHaveBeenCalledWith(
expect.objectContaining({ name: 'test-ext' }),
'test-id',
'TEST_VAR',
expect.any(Function),
'user',
tempWorkspaceDir,
);
});
it('should handle missing extension', async () => {
mockExtensionManager.getExtensions.mockReturnValue([]);
await runCommand('config missing-ext TEST_VAR');
expect(updateSetting).not.toHaveBeenCalled();
});
it('should reject invalid extension names', async () => {
await runCommand('config ../invalid TEST_VAR');
expect(debugLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name'),
);
await runCommand('config ext/with/slash TEST_VAR');
expect(debugLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name'),
);
});
});
describe('Extension configuration (all settings)', () => {
it('should configure all settings for an extension', async () => {
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
setupExtension('test-ext', settings);
(getScopedEnvContents as Mock).mockResolvedValue({});
(updateSetting as Mock).mockResolvedValue(undefined);
await runCommand('config test-ext');
expect(debugLogger.log).toHaveBeenCalledWith(
'Configuring settings for "test-ext"...',
);
expect(updateSetting).toHaveBeenCalledWith(
expect.objectContaining({ name: 'test-ext' }),
'test-id',
'VAR_1',
expect.any(Function),
'user',
tempWorkspaceDir,
);
});
it('should verify overwrite if setting is already set', async () => {
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
setupExtension('test-ext', settings);
(getScopedEnvContents as Mock).mockImplementation(
async (_config, _id, scope) => {
if (scope === 'user') return { VAR_1: 'existing' };
return {};
},
);
(prompts as unknown as Mock).mockResolvedValue({ confirm: true });
(updateSetting as Mock).mockResolvedValue(undefined);
await runCommand('config test-ext');
expect(prompts).toHaveBeenCalledWith(
expect.objectContaining({
type: 'confirm',
message: expect.stringContaining('is already set. Overwrite?'),
}),
);
expect(updateSetting).toHaveBeenCalled();
});
it('should note if setting is configured in workspace', async () => {
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
setupExtension('test-ext', settings);
(getScopedEnvContents as Mock).mockImplementation(
async (_config, _id, scope) => {
if (scope === 'workspace') return { VAR_1: 'workspace_value' };
return {};
},
);
(updateSetting as Mock).mockResolvedValue(undefined);
await runCommand('config test-ext');
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('is already configured in the workspace scope'),
);
});
it('should skip update if user denies overwrite', async () => {
const settings = [{ name: 'Setting 1', envVar: 'VAR_1' }];
setupExtension('test-ext', settings);
(getScopedEnvContents as Mock).mockResolvedValue({ VAR_1: 'existing' });
(prompts as unknown as Mock).mockResolvedValue({ confirm: false });
await runCommand('config test-ext');
expect(prompts).toHaveBeenCalled();
expect(updateSetting).not.toHaveBeenCalled();
});
});
describe('Configure all extensions', () => {
it('should configure settings for all installed extensions', async () => {
const ext1 = {
name: 'ext1',
path: '/p1',
id: 'id1',
settings: [{ envVar: 'V1' }],
};
const ext2 = {
name: 'ext2',
path: '/p2',
id: 'id2',
settings: [{ envVar: 'V2' }],
};
mockExtensionManager.getExtensions.mockReturnValue([ext1, ext2]);
mockExtensionManager.loadExtensionConfig.mockImplementation(
async (path) => {
if (path === '/p1')
return { name: 'ext1', settings: [{ name: 'S1', envVar: 'V1' }] };
if (path === '/p2')
return { name: 'ext2', settings: [{ name: 'S2', envVar: 'V2' }] };
return null;
},
);
(getScopedEnvContents as Mock).mockResolvedValue({});
(updateSetting as Mock).mockResolvedValue(undefined);
await runCommand('config');
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Configuring settings for "ext1"'),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Configuring settings for "ext2"'),
);
expect(updateSetting).toHaveBeenCalledTimes(2);
});
it('should log if no extensions installed', async () => {
mockExtensionManager.getExtensions.mockReturnValue([]);
await runCommand('config');
expect(debugLogger.log).toHaveBeenCalledWith('No extensions installed.');
});
});
});
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import type { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js';
import {
configureAllExtensions,
configureExtension,
configureSpecificSetting,
getExtensionManager,
} from './utils.js';
import { loadSettings } from '../../config/settings.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
interface ConfigureArgs {
name?: string;
setting?: string;
scope: string;
}
export const configureCommand: CommandModule<object, ConfigureArgs> = {
command: 'config [name] [setting]',
describe: 'Configure extension settings.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'Name of the extension to configure.',
type: 'string',
})
.positional('setting', {
describe: 'The specific setting to configure (name or env var).',
type: 'string',
})
.option('scope', {
describe: 'The scope to set the setting in.',
type: 'string',
choices: ['user', 'workspace'],
default: 'user',
}),
handler: async (args) => {
const { name, setting, scope } = args;
const settings = loadSettings(process.cwd()).merged;
if (!(settings.experimental?.extensionConfig ?? true)) {
coreEvents.emitFeedback(
'error',
'Extension configuration is currently disabled. Enable it by setting "experimental.extensionConfig" to true.',
);
await exitCli();
return;
}
if (name) {
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
debugLogger.error(
'Invalid extension name. Names cannot contain path separators or "..".',
);
return;
}
}
const extensionManager = await getExtensionManager();
// Case 1: Configure specific setting for an extension
if (name && setting) {
await configureSpecificSetting(
extensionManager,
name,
setting,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
// Case 2: Configure all settings for an extension
else if (name) {
await configureExtension(
extensionManager,
name,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
// Case 3: Configure all extensions
else {
await configureAllExtensions(
extensionManager,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope as ExtensionSettingScope,
);
}
await exitCli();
},
};
@@ -0,0 +1,250 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { handleDisable, disableCommand } from './disable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { getErrorMessage } from '@google/gemini-cli-core';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn(),
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions disable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockGetErrorMessage = vi.mocked(getErrorMessage);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.disableExtension = vi
.fn()
.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleDisable', () => {
it.each([
{
name: 'my-extension',
scope: undefined,
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully disabled for scope "undefined".',
},
{
name: 'my-extension',
scope: 'user',
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully disabled for scope "user".',
},
{
name: 'my-extension',
scope: 'workspace',
expectedScope: SettingScope.Workspace,
expectedLog:
'Extension "my-extension" successfully disabled for scope "workspace".',
},
])(
'should disable an extension in the $expectedScope scope when scope is $scope',
async ({ name, scope, expectedScope, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleDisable({ name, scope });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(
mockExtensionManager.prototype.loadExtensions,
).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.disableExtension,
).toHaveBeenCalledWith(name, expectedScope);
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
it('should log an error message and exit with code 1 when extension disabling fails', async () => {
const mockProcessExit = vi
.spyOn(process, 'exit')
.mockImplementation((() => {}) as (
code?: string | number | null | undefined,
) => never);
const error = new Error('Disable failed');
(
mockExtensionManager.prototype.disableExtension as Mock
).mockRejectedValue(error);
mockGetErrorMessage.mockReturnValue('Disable failed message');
await handleDisable({ name: 'my-extension' });
expect(emitConsoleLog).toHaveBeenCalledWith(
'error',
'Disable failed message',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
mockProcessExit.mockRestore();
});
});
describe('disableCommand', () => {
const command = disableCommand;
it('should have correct command and describe', () => {
expect(command.command).toBe('disable [--scope] <name>');
expect(command.describe).toBe('Disables an extension.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional and option arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
describe: 'The name of the extension to disable.',
type: 'string',
});
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
describe: 'The scope to disable the extension in.',
type: 'string',
default: SettingScope.User,
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for invalid scope', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`;
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
expectedError,
);
});
it.each(['user', 'workspace', 'USER', 'WorkSpace'])(
'check function should return true for valid scope "%s"',
(scope) => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
expect(checkCallback({ scope })).toBe(true);
},
);
});
it('handler should trigger extension disabling', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
name: string;
scope: string;
[key: string]: unknown;
}
const argv: TestArgv = {
name: 'test-ext',
scope: 'workspace',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
argv,
);
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(mockExtensionManager.prototype.loadExtensions).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.disableExtension,
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
'Extension "test-ext" successfully disabled for scope "workspace".',
);
mockCwd.mockRestore();
});
});
});
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface DisableArgs {
name: string;
scope?: string;
}
export async function handleDisable(args: DisableArgs) {
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
try {
if (args.scope?.toLowerCase() === 'workspace') {
await extensionManager.disableExtension(
args.name,
SettingScope.Workspace,
);
} else {
await extensionManager.disableExtension(args.name, SettingScope.User);
}
debugLogger.log(
`Extension "${args.name}" successfully disabled for scope "${args.scope}".`,
);
} catch (error) {
debugLogger.error(getErrorMessage(error));
process.exit(1);
}
}
export const disableCommand: CommandModule = {
command: 'disable [--scope] <name>',
describe: 'Disables an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to disable.',
type: 'string',
})
.option('scope', {
describe: 'The scope to disable the extension in.',
type: 'string',
default: SettingScope.User,
})
.check((argv) => {
if (
argv.scope &&
!Object.values(SettingScope)
.map((s) => s.toLowerCase())
.includes(argv.scope.toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`,
);
}
return true;
}),
handler: async (argv) => {
await handleDisable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
},
};
@@ -0,0 +1,280 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { format } from 'node:util';
import { type Argv } from 'yargs';
import { handleEnable, enableCommand } from './enable.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
loadSettings,
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { FatalConfigError } from '@google/gemini-cli-core';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
const debugLogger = vi.hoisted(() => ({
log: vi.fn((message, ...args) => {
emitConsoleLog('log', format(message, ...args));
}),
error: vi.fn((message, ...args) => {
emitConsoleLog('error', format(message, ...args));
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
coreEvents: {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn((error: { message: string }) => error.message),
FatalConfigError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalConfigError';
}
},
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../config/extensions/consent.js');
vi.mock('../../config/extensions/extensionSettings.js');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
const mockEnablementInstance = vi.hoisted(() => ({
getDisplayState: vi.fn(),
enable: vi.fn(),
clearSessionDisable: vi.fn(),
autoEnableServers: vi.fn(),
}));
vi.mock('../../config/mcp/mcpServerEnablement.js', () => ({
McpServerEnablementManager: {
getInstance: () => mockEnablementInstance,
},
}));
describe('extensions enable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockExtensionManager = vi.mocked(ExtensionManager);
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
mockExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
mockExtensionManager.prototype.enableExtension = vi.fn();
mockExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
mockEnablementInstance.getDisplayState.mockReset();
mockEnablementInstance.enable.mockReset();
mockEnablementInstance.clearSessionDisable.mockReset();
mockEnablementInstance.autoEnableServers.mockReset();
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleEnable', () => {
it.each([
{
name: 'my-extension',
scope: undefined,
expectedScope: SettingScope.User,
expectedLog:
'Extension "my-extension" successfully enabled in all scopes.',
},
{
name: 'my-extension',
scope: 'workspace',
expectedScope: SettingScope.Workspace,
expectedLog:
'Extension "my-extension" successfully enabled for scope "workspace".',
},
])(
'should enable an extension in the $expectedScope scope when scope is $scope',
async ({ name, scope, expectedScope, expectedLog }) => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
await handleEnable({ name, scope });
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: '/test/dir',
}),
);
expect(
mockExtensionManager.prototype.loadExtensions,
).toHaveBeenCalled();
expect(
mockExtensionManager.prototype.enableExtension,
).toHaveBeenCalledWith(name, expectedScope);
expect(emitConsoleLog).toHaveBeenCalledWith('log', expectedLog);
mockCwd.mockRestore();
},
);
it('should throw FatalConfigError when extension enabling fails', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
const error = new Error('Enable failed');
(
mockExtensionManager.prototype.enableExtension as Mock
).mockImplementation(() => {
throw error;
});
const promise = handleEnable({ name: 'my-extension' });
await expect(promise).rejects.toThrow(FatalConfigError);
await expect(promise).rejects.toThrow('Enable failed');
mockCwd.mockRestore();
});
it('should auto-enable disabled MCP servers for the extension', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
mockEnablementInstance.autoEnableServers.mockResolvedValue([
'test-server',
]);
mockExtensionManager.prototype.getExtensions = vi
.fn()
.mockReturnValue([
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
]);
await handleEnable({ name: 'my-extension' });
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
'test-server',
]);
expect(emitConsoleLog).toHaveBeenCalledWith(
'log',
expect.stringContaining("MCP server 'test-server' was disabled"),
);
mockCwd.mockRestore();
});
it('should not log when MCP servers are already enabled', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
mockEnablementInstance.autoEnableServers.mockResolvedValue([]);
mockExtensionManager.prototype.getExtensions = vi
.fn()
.mockReturnValue([
{ name: 'my-extension', mcpServers: { 'test-server': {} } },
]);
await handleEnable({ name: 'my-extension' });
expect(mockEnablementInstance.autoEnableServers).toHaveBeenCalledWith([
'test-server',
]);
expect(emitConsoleLog).not.toHaveBeenCalledWith(
'log',
expect.stringContaining("MCP server 'test-server' was disabled"),
);
mockCwd.mockRestore();
});
});
describe('enableCommand', () => {
const command = enableCommand;
it('should have correct command and describe', () => {
expect(command.command).toBe('enable [--scope] <name>');
expect(command.describe).toBe('Enables an extension.');
});
describe('builder', () => {
interface MockYargs {
positional: Mock;
option: Mock;
check: Mock;
}
let yargsMock: MockYargs;
beforeEach(() => {
yargsMock = {
positional: vi.fn().mockReturnThis(),
option: vi.fn().mockReturnThis(),
check: vi.fn().mockReturnThis(),
};
});
it('should configure positional and option arguments', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
expect(yargsMock.positional).toHaveBeenCalledWith('name', {
describe: 'The name of the extension to enable.',
type: 'string',
});
expect(yargsMock.option).toHaveBeenCalledWith('scope', {
describe:
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
type: 'string',
});
expect(yargsMock.check).toHaveBeenCalled();
});
it('check function should throw for invalid scope', () => {
(command.builder as (yargs: Argv) => Argv)(
yargsMock as unknown as Argv,
);
const checkCallback = yargsMock.check.mock.calls[0][0];
const expectedError = `Invalid scope: invalid. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`;
expect(() => checkCallback({ scope: 'invalid' })).toThrow(
expectedError,
);
});
});
it('handler should call handleEnable', async () => {
const mockCwd = vi.spyOn(process, 'cwd').mockReturnValue('/test/dir');
interface TestArgv {
name: string;
scope: string;
[key: string]: unknown;
}
const argv: TestArgv = {
name: 'test-ext',
scope: 'workspace',
_: [],
$0: '',
};
await (command.handler as unknown as (args: TestArgv) => Promise<void>)(
argv,
);
expect(
mockExtensionManager.prototype.enableExtension,
).toHaveBeenCalledWith('test-ext', SettingScope.Workspace);
mockCwd.mockRestore();
});
});
});
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
debugLogger,
FatalConfigError,
getErrorMessage,
} from '@google/gemini-cli-core';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
interface EnableArgs {
name: string;
scope?: string;
}
export async function handleEnable(args: EnableArgs) {
const workingDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir: workingDir,
requestConsent: requestConsentNonInteractive,
requestSetting: promptForSetting,
settings: loadSettings(workingDir).merged,
});
await extensionManager.loadExtensions();
try {
if (args.scope?.toLowerCase() === 'workspace') {
await extensionManager.enableExtension(args.name, SettingScope.Workspace);
} else {
await extensionManager.enableExtension(args.name, SettingScope.User);
}
// Auto-enable any disabled MCP servers for this extension
const extension = extensionManager
.getExtensions()
.find((e) => e.name === args.name);
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers ?? {}),
);
for (const serverName of enabledServers) {
debugLogger.log(
`MCP server '${serverName}' was disabled - now enabled.`,
);
}
// Note: No restartServer() - CLI exits immediately, servers load on next session
}
if (args.scope) {
debugLogger.log(
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
);
} else {
debugLogger.log(
`Extension "${args.name}" successfully enabled in all scopes.`,
);
}
} catch (error) {
throw new FatalConfigError(getErrorMessage(error));
}
}
export const enableCommand: CommandModule = {
command: 'enable [--scope] <name>',
describe: 'Enables an extension.',
builder: (yargs) =>
yargs
.positional('name', {
describe: 'The name of the extension to enable.',
type: 'string',
})
.option('scope', {
describe:
'The scope to enable the extension in. If not set, will be enabled in all scopes.',
type: 'string',
})
.check((argv) => {
if (
argv.scope &&
!Object.values(SettingScope)
.map((s) => s.toLowerCase())
.includes(argv.scope.toLowerCase())
) {
throw new Error(
`Invalid scope: ${argv.scope}. Please use one of ${Object.values(
SettingScope,
)
.map((s) => s.toLowerCase())
.join(', ')}.`,
);
}
return true;
}),
handler: async (argv) => {
await handleEnable({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
name: argv['name'] as string,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
scope: argv['scope'] as string,
});
await exitCli();
},
};
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-error.log
yarn-debug.log
# Build output
dist/
# OS metadata
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode/
.idea/
@@ -0,0 +1,6 @@
prompt = """
Please summarize the findings for the pattern `{{args}}`.
Search Results:
!{grep -r {{args}} .}
"""
@@ -0,0 +1,4 @@
{
"name": "custom-commands",
"version": "1.0.0"
}
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-error.log
yarn-debug.log
# Build output
dist/
# OS metadata
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode/
.idea/
@@ -0,0 +1,5 @@
{
"name": "excludeTools",
"version": "1.0.0",
"excludeTools": ["run_shell_command(rm -rf)"]
}
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-error.log
yarn-debug.log
# Build output
dist/
# OS metadata
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode/
.idea/
@@ -0,0 +1,4 @@
{
"name": "hooks-example",
"version": "1.0.0"
}
@@ -0,0 +1,14 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node ${extensionPath}/scripts/on-start.js"
}
]
}
]
}
}
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
console.log(
'Session Started! This is running from a script in the hooks-example extension.',
);
@@ -0,0 +1,26 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-error.log
yarn-debug.log
# Build output
dist/
# OS metadata
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDEs
.vscode/
.idea/
@@ -0,0 +1,35 @@
# MCP Server Example
This is a basic example of an MCP (Model Context Protocol) server used as a
Gemini CLI extension. It demonstrates how to expose tools and prompts to the
Gemini CLI.
## Description
The contents of this directory are a valid MCP server implementation using the
`@modelcontextprotocol/sdk`. It exposes:
- A tool `fetch_posts` that mock-fetches posts.
- A prompt `poem-writer`.
## Structure
- `example.js`: The main server entry point.
- `gemini-extension.json`: The configuration file that tells Gemini CLI how to
use this extension.
- `package.json`: Helper for dependencies.
## How to Use
1. Navigate to this directory:
```bash
cd packages/cli/src/commands/extensions/examples/mcp-server
```
2. Install dependencies:
```bash
npm install
```
This example is typically used by `gemini extensions new`.
@@ -0,0 +1,60 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'prompt-server',
version: '1.0.0',
});
server.registerTool(
'fetch_posts',
{
description: 'Fetches a list of posts from a public API.',
inputSchema: z.object({}).shape,
},
async () => {
const apiResponse = await fetch(
'https://jsonplaceholder.typicode.com/posts',
);
const posts = await apiResponse.json();
const response = { posts: posts.slice(0, 5) };
return {
content: [
{
type: 'text',
text: JSON.stringify(response),
},
],
};
},
);
server.registerPrompt(
'poem-writer',
{
title: 'Poem Writer',
description: 'Write a nice haiku',
argsSchema: { title: z.string(), mood: z.string().optional() },
},
({ title, mood }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
},
},
],
}),
);
const transport = new StdioServerTransport();
await server.connect(transport);
@@ -0,0 +1,11 @@
{
"name": "mcp-server-example",
"version": "1.0.0",
"mcpServers": {
"nodeServer": {
"command": "node",
"args": ["${extensionPath}${/}example.js"],
"cwd": "${extensionPath}"
}
}
}
@@ -0,0 +1,11 @@
{
"name": "mcp-server-example",
"version": "1.0.0",
"description": "Example MCP Server for Gemini CLI Extension",
"type": "module",
"main": "example.js",
"dependencies": {
"@modelcontextprotocol/sdk": "1.23.0",
"zod": "3.22.4"
}
}
@@ -0,0 +1,41 @@
# Policy engine example extension
This extension demonstrates how to contribute security rules and safety checkers
to the Gemini CLI Policy Engine.
## Description
The extension uses a `policies/` directory containing `.toml` files to define:
- A rule that requires user confirmation for `rm -rf` commands.
- A rule that denies searching for sensitive files (like `.env`) using `grep`.
- A safety checker that validates file paths for all write operations.
## Structure
- `gemini-extension.json`: The manifest file.
- `policies/`: Contains the `.toml` policy files.
## How to use
1. Link this extension to your local Gemini CLI installation:
```bash
gemini extensions link packages/cli/src/commands/extensions/examples/policies
```
2. Restart your Gemini CLI session.
3. **Observe the policies:**
- Try asking the model to delete a directory: The policy engine will prompt
you for confirmation due to the `rm -rf` rule.
- Try asking the model to search for secrets: The `grep` rule will deny the
request and display the custom deny message.
- Any file write operation will now be processed through the `allowed-path`
safety checker.
## Security note
For security, Gemini CLI ignores any `allow` decisions or `yolo` mode
configurations contributed by extensions. This ensures that extensions can
strengthen security but cannot bypass user confirmation.
@@ -0,0 +1,5 @@
{
"name": "policy-example",
"version": "1.0.0",
"description": "An example extension demonstrating Policy Engine support."
}
@@ -0,0 +1,28 @@
# Example Policy Rules for Gemini CLI Extension
#
# Extensions run in Tier 2 (Extension Tier).
# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored.
# Rule: Always ask the user before running a specific dangerous shell command.
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm -rf"
decision = "ask_user"
priority = 100
# Rule: Deny access to sensitive files using the grep tool.
[[rule]]
toolName = "grep_search"
argsPattern = "(\.env|id_rsa|passwd)"
decision = "deny"
priority = 200
denyMessage = "Access to sensitive credentials or system files is restricted by the policy-example extension."
# Safety Checker: Apply path validation to all write operations.
[[safety_checker]]
toolName = ["write_file", "replace"]
priority = 300
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]

Some files were not shown because too many files have changed in this diff Show More