chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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`
|
||||
@@ -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
|
||||
Assist’s 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 A2A’s 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 extension’s 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
|
||||
tool’s 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`.
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export * from './src/index.js';
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 }>();
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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/],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 'A' to add an item. Press 'E' to edit
|
||||
last item. Press 'Ctrl+E' to scroll to end. Press
|
||||
'Esc' 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 });
|
||||
@@ -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();
|
||||
@@ -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.
|
||||
"
|
||||
`;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/
|
||||
+6
@@ -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
Reference in New Issue
Block a user